Merge remote-tracking branch 'origin/dev/4.11.x' into dev/4.11.x

# Conflicts:
#	uv.lock
#	web/src/i18n/locales/en-US.ts
#	web/src/i18n/locales/ja-JP.ts
#	web/src/i18n/locales/zh-Hans.ts
#	web/src/i18n/locales/zh-Hant.ts
This commit is contained in:
fdc310
2026-09-19 11:00:45 +08:00
357 changed files with 43727 additions and 6682 deletions
+1 -1
View File
@@ -16,7 +16,7 @@ asciiart = r"""
|___/
⭐️ Open Source 开源地址: https://github.com/langbot-app/LangBot
📖 Documentation 文档地址: https://docs.langbot.app
📖 Documentation 文档地址: https://langbot.app/docs
"""
@@ -249,6 +249,20 @@ class RunnerContextBuilder:
'timestamp': event.event_time or int(time.time()),
}
# A legacy Tbox resume must use the persisted original bot, not a
# newly reconstructed callback envelope. Ordinary sender mode is unchanged.
bot_id = event.bot_id
if event.event_type == 'interaction.submitted' and binding.runner_config.get('user-id-source') == 'legacy-bot':
identity = event.legacy_identity
bot_id = (
identity.bot_id
if identity is not None
and identity.workspace_id == event.workspace_id
and identity.bot_id == event.bot_id
and identity.pipeline_id == (binding.processor_id or binding.agent_id)
else None
)
# Build conversation context from event
conversation: ConversationContext | None = None
if event.conversation_id:
@@ -256,13 +270,39 @@ class RunnerContextBuilder:
'session_id': None,
'conversation_id': event.conversation_id,
'thread_id': event.thread_id,
'launcher_type': None, # Will be filled from actor/subject if needed
'launcher_type': None,
'launcher_id': None,
'sender_id': event.actor.actor_id if event.actor else None,
'bot_id': event.bot_id,
'bot_id': bot_id,
'workspace_id': event.workspace_id,
}
identity = event.legacy_identity
if (
binding.runner_config.get('user-id-source') == 'legacy-session'
and identity is not None
and identity.workspace_id == event.workspace_id
and identity.bot_id == event.bot_id
and identity.pipeline_id == (binding.processor_id or binding.agent_id)
and binding.processor_type == 'pipeline'
):
# Coze used Query itself; Dify/n8n used Query.session, which may
# deliberately differ (e.g. group per-member session isolation).
if binding.runner_id == 'plugin:langbot-team/CozeAgent/default':
launcher_type = identity.query_launcher_type
launcher_id = identity.query_launcher_id
elif binding.runner_id in {
'plugin:langbot-team/DifyAgent/default',
'plugin:langbot-team/N8nAgent/default',
}:
launcher_type = identity.session_launcher_type
launcher_id = identity.session_launcher_id
else:
launcher_type = launcher_id = None
if launcher_type in ('group', 'person') and launcher_id:
conversation['launcher_type'] = launcher_type
conversation['launcher_id'] = launcher_id
# Build event context (Protocol v1 event-first)
event_context = {
'event_id': event.event_id,
@@ -322,7 +362,7 @@ class RunnerContextBuilder:
'trace_id': run_id,
'deadline_at': self._build_deadline_from_binding(binding),
'metadata': {
'bot_id': event.bot_id,
'bot_id': bot_id,
'workspace_id': event.workspace_id,
'streaming_supported': event.delivery.supports_streaming,
'model_context_window_tokens': model_context_window_tokens,
@@ -331,6 +371,7 @@ class RunnerContextBuilder:
# Build delivery context
delivery_context = {
'automatic_reply': binding.processor_type == 'pipeline',
'surface': event.delivery.surface,
'reply_target': event.delivery.reply_target,
'supports_streaming': event.delivery.supports_streaming,
@@ -480,6 +521,7 @@ class RunnerContextBuilder:
'reason': 'current_event_only',
},
'available_apis': {
'box': True,
'prompt_get': False,
'history_page': history_page_enabled,
'history_search': history_search_enabled,
@@ -4,7 +4,6 @@ from __future__ import annotations
import copy
import hashlib
import json
import typing
from langbot_plugin.api.entities.builtin.pipeline import query as pipeline_query
@@ -13,11 +12,9 @@ from langbot_plugin.api.entities.builtin.platform import message as platform_mes
from langbot_plugin.api.entities.builtin.provider import message as provider_message
from langbot_plugin.api.entities.builtin.provider import session as provider_session
from ...utils import constants
from .host_models import AgentEventEnvelope
HOST_BOX_SCOPE_VARIABLE = '_host_box_scope'
AUTHORIZED_SKILLS_VARIABLE = '_pipeline_bound_skills'
MCP_RESOURCE_ATTACHMENTS_VARIABLE = '_pipeline_mcp_resource_attachments'
MCP_RESOURCE_AGENT_READ_ENABLED_VARIABLE = '_pipeline_mcp_resource_agent_read_enabled'
@@ -102,29 +99,12 @@ def prepare_execution_query(
authorized_skill_names: list[str],
) -> pipeline_query.Query:
"""Attach Host-owned execution metadata without changing Query identity."""
variables = prepare_box_scope(query, event, preserve_existing=True)
variables[AUTHORIZED_SKILLS_VARIABLE] = list(dict.fromkeys(authorized_skill_names))
return query
def prepare_box_scope(
query: pipeline_query.Query,
event: AgentEventEnvelope,
*,
preserve_existing: bool = False,
) -> dict[str, typing.Any]:
"""Attach the Host Box scope before any Query-side file staging."""
variables = getattr(query, 'variables', None)
variables = query.variables
if not isinstance(variables, dict):
variables = {}
query.variables = variables
existing_scope = variables.get(HOST_BOX_SCOPE_VARIABLE)
if preserve_existing and isinstance(existing_scope, str) and existing_scope.strip():
return variables
variables[HOST_BOX_SCOPE_VARIABLE] = build_host_box_scope(event, query=query)
return variables
variables[AUTHORIZED_SKILLS_VARIABLE] = list(dict.fromkeys(authorized_skill_names))
return query
def build_execution_query(
@@ -168,50 +148,10 @@ def build_execution_query(
variables={},
resp_messages=[],
)
query.variables[HOST_BOX_SCOPE_VARIABLE] = build_host_box_scope(event)
query.variables[AUTHORIZED_SKILLS_VARIABLE] = list(dict.fromkeys(authorized_skill_names))
return query
def build_host_box_scope(
event: AgentEventEnvelope,
*,
query: pipeline_query.Query | None = None,
) -> str | None:
"""Return a stable Host scope for a Query session or event."""
target_type, target_id = _resolve_box_target(event, query)
if target_type is None or target_id is None:
return None
capabilities = event.delivery.platform_capabilities or {}
adapter_identity = None
if query is not None:
adapter = getattr(query, 'adapter', None)
if adapter is not None:
adapter_identity = adapter.__class__.__name__
adapter_identity = _first_present(
adapter_identity,
capabilities.get('adapter'),
capabilities.get('source'),
event.source if query is None else None,
)
return json.dumps(
{
'instance_id': _nonempty(constants.instance_id),
'workspace_id': _nonempty(event.workspace_id),
'bot_id': _nonempty(event.bot_id),
'platform_adapter': adapter_identity,
'target_type': target_type,
'target_id': target_id,
'thread_id': _nonempty(event.thread_id),
},
ensure_ascii=False,
sort_keys=True,
separators=(',', ':'),
)
def _resolve_session_identity(
event: AgentEventEnvelope,
) -> tuple[provider_session.LauncherTypes, str, str]:
@@ -249,42 +189,6 @@ def _resolve_session_identity(
return provider_session.LauncherTypes(launcher_type_value), launcher_id, sender_id
def _resolve_box_target(
event: AgentEventEnvelope,
query: pipeline_query.Query | None,
) -> tuple[str | None, str | None]:
if query is not None:
launcher_type = getattr(query, 'launcher_type', None)
if hasattr(launcher_type, 'value'):
launcher_type = launcher_type.value
launcher_id = getattr(query, 'launcher_id', None)
normalized_type = _nonempty(launcher_type)
normalized_id = _nonempty(launcher_id)
if normalized_type is not None and normalized_id is not None:
return normalized_type, normalized_id
reply_target = event.delivery.reply_target or {}
target_type = _first_present(
reply_target.get('target_type'),
reply_target.get('launcher_type'),
)
target_id = _first_present(
reply_target.get('target_id'),
reply_target.get('launcher_id'),
)
if target_type is not None and target_id is not None:
return target_type, target_id
conversation_id = _nonempty(event.conversation_id)
if conversation_id is not None:
return 'conversation', conversation_id
event_id = _nonempty(event.event_id)
if event_id is not None:
return 'event', event_id
return None, None
def _build_message_chain(event: AgentEventEnvelope) -> platform_message.MessageChain:
text = event.input.to_text()
if not text:
@@ -17,6 +17,18 @@ from langbot_plugin.api.entities.builtin.runner.input import AgentInput
from langbot_plugin.api.entities.builtin.runner.delivery import DeliveryContext
class LegacyRunnerIdentity(pydantic.BaseModel):
"""Host-captured native Query identity; never populated from event data/params."""
workspace_id: str | None = None
bot_id: str | None = None
pipeline_id: str | None = None
query_launcher_type: str | None = None
query_launcher_id: str | None = None
session_launcher_type: str | None = None
session_launcher_id: str | None = None
class AgentEventEnvelope(pydantic.BaseModel):
"""Event envelope for LangBot Host event gateway.
@@ -69,6 +81,9 @@ class AgentEventEnvelope(pydantic.BaseModel):
data: dict[str, typing.Any] = pydantic.Field(default_factory=dict)
"""Small structured event payload. Large payloads should be referenced via raw_ref."""
legacy_identity: LegacyRunnerIdentity | None = None
"""Host-only provenance, separate from untrusted event payload and adapter params."""
# Binding scope types
class BindingScope(pydantic.BaseModel):
@@ -4,6 +4,7 @@ from __future__ import annotations
from langbot.pkg.telemetry import diagnostics
import copy
import json
import time
import typing
@@ -14,7 +15,7 @@ from langbot_plugin.api.entities.builtin.platform import message as platform_mes
from .descriptor import RunnerDescriptor
from .errors import RunnerProtocolError
from .host_models import AgentBinding, AgentEventEnvelope
from .host_models import AgentBinding, AgentEventEnvelope, LegacyRunnerIdentity
from .interaction_store import InteractionStore
@@ -152,7 +153,12 @@ class InteractionManager:
runner_id=descriptor.id,
processor_type=binding.processor_type,
processor_id=processor_id,
request=request_data,
request={
**request_data,
'_host_legacy_identity': event.legacy_identity.model_dump(mode='json')
if event.legacy_identity is not None
else None,
},
delivery_target=reply_target,
replaces_interaction_id=(update_target or {}).get('interaction_id'),
bot_id=event.bot_id,
@@ -161,6 +167,7 @@ class InteractionManager:
thread_id=event.thread_id,
actor_id=event.actor.actor_id if event.actor else None,
expires_at=expires_at,
**self._pipeline_admission(binding, descriptor, adapter_context),
)
try:
@@ -204,6 +211,50 @@ class InteractionManager:
diagnostics.set_outcome('waiting', reason_code='waiting')
return True
@staticmethod
def _pipeline_admission(binding, descriptor, adapter_context):
if binding.processor_type != 'pipeline':
return {}
query = (adapter_context or {}).get('_query')
expected = copy.deepcopy((adapter_context or {}).get('_pipeline_expected_config'))
session = getattr(query, 'session', None)
conversation = (adapter_context or {}).get('_pipeline_conversation')
def authority():
from .config_resolver import RunnerConfigResolver
return (
query is not None
and session is not None
and conversation is not None
and isinstance(expected, dict)
and session.using_conversation is conversation
and getattr(query, 'pipeline_uuid', None) == binding.processor_id
and RunnerConfigResolver.resolve_runner_id(expected) == descriptor.id == binding.runner_id
and RunnerConfigResolver.resolve_runner_config(expected, descriptor.id) == binding.runner_config
)
return {'expected_config': expected, 'authority_check': authority}
async def restore_legacy_identity(self, event: AgentEventEnvelope, binding: AgentBinding) -> None:
"""Resume only from the scoped, consumed Host record, never callback fields."""
event.legacy_identity = None
submission = getattr(event.input, 'interaction', None)
if submission is None:
return
if hasattr(submission, 'model_dump'):
submission = submission.model_dump(mode='json')
record = await self.store.find_resume_request(event=event, binding=binding, submission=submission)
if record is None:
return
identity = record['request'].get('_host_legacy_identity')
if not isinstance(identity, dict):
return
try:
event.legacy_identity = LegacyRunnerIdentity.model_validate(identity)
except pydantic.ValidationError:
return
@diagnostics.observe('interaction', 'interaction.acknowledge', source='platform', stage='ack')
async def acknowledge_submission(self, record: dict[str, typing.Any], adapter: typing.Any) -> None:
"""Best-effort transition of submitted controls into a read-only state."""
@@ -14,6 +14,8 @@ from sqlalchemy.ext.asyncio import AsyncEngine, AsyncSession
from sqlalchemy.orm import sessionmaker
from ...entity.persistence.agent_interaction import AgentInteraction
from ...persistence.tenant_uow import TenantUnitOfWork
from ...persistence.pipeline_admission import lock_pipeline_admission
UTC = datetime.timezone.utc
@@ -49,6 +51,12 @@ def _utc_now() -> datetime.datetime:
return datetime.datetime.now(UTC)
def _db_datetime(value):
# Existing schema is TIMESTAMP WITHOUT TIME ZONE; persist UTC naive on
# both backends and normalize to aware UTC only for application comparisons.
return _as_utc(value).replace(tzinfo=None) if value is not None else None
def _as_utc(value: datetime.datetime | None) -> datetime.datetime | None:
if value is None:
return None
@@ -113,6 +121,8 @@ class InteractionStore:
thread_id: str | None = None,
actor_id: str | None = None,
expires_at: int | float | None = None,
expected_config: dict | None = None,
authority_check: typing.Callable[[], bool] | None = None,
) -> tuple[dict[str, typing.Any], str]:
"""Persist a request and return its record plus one-time callback token."""
if not interaction_id or not run_id or not binding_id or not runner_id or not processor_id:
@@ -145,11 +155,32 @@ class InteractionStore:
delivery_target_json=delivery_target_json,
replaces_interaction_id=replaces_interaction_id,
callback_token_hash=_token_hash(callback_token),
expires_at=expires_at_dt,
created_at=now,
updated_at=now,
expires_at=_db_datetime(expires_at_dt),
created_at=_db_datetime(now),
updated_at=_db_datetime(now),
)
if processor_type == 'pipeline':
# Require Host-captured configuration and live conversation authority;
# runner output must never supply either of these admission inputs.
if not workspace_id or not isinstance(expected_config, dict) or authority_check is None:
raise InteractionScopeError('Pipeline interaction authority unavailable')
try:
async with TenantUnitOfWork(self.engine, workspace_id) as uow:
current = await lock_pipeline_admission(uow.session, workspace_id, processor_id)
if current != expected_config or authority_check() is not True:
raise InteractionScopeError('Pipeline interaction authority changed')
existing = await self._get_by_run_interaction(uow.session, run_id, interaction_id)
if existing is not None:
raise DuplicateInteractionError('Interaction already exists')
uow.session.add(row)
await uow.session.flush()
return self._to_dict(row), callback_token
except ValueError as exc:
raise InteractionScopeError('Pipeline interaction authority unavailable') from exc
except IntegrityError as exc:
raise DuplicateInteractionError('Interaction already exists') from exc
async with self._session_factory() as session:
existing = await self._get_by_run_interaction(session, run_id, interaction_id)
if existing is not None:
@@ -182,7 +213,7 @@ class InteractionStore:
AgentInteraction.interaction_id == interaction_id,
AgentInteraction.status.in_(['pending', 'submitted']),
)
.values(delivery_result_json=payload, updated_at=_utc_now())
.values(delivery_result_json=payload, updated_at=_db_datetime(_utc_now()))
)
await session.commit()
return result.rowcount == 1
@@ -228,6 +259,38 @@ class InteractionStore:
row = result.scalar_one_or_none()
return self._to_dict(row) if row is not None else None
async def find_resume_request(self, *, event, binding, submission) -> dict[str, typing.Any] | None:
"""Resolve persisted provenance with exact tenant/processor/callback scope.
Ambiguous identities fail closed instead of picking the newest run.
"""
if not isinstance(submission, dict) or not submission.get('interaction_id'):
return None
target = event.delivery.reply_target or {}
target_type, target_id = target.get('target_type'), target.get('target_id')
conversation_id = f'{target_type}_{target_id}' if target_type and target_id else event.conversation_id
conditions = [
AgentInteraction.interaction_id == submission['interaction_id'],
AgentInteraction.status == 'submitted',
AgentInteraction.binding_id == binding.binding_id,
AgentInteraction.runner_id == binding.runner_id,
AgentInteraction.processor_type == binding.processor_type,
AgentInteraction.processor_id == (binding.processor_id or binding.agent_id),
]
for column, value in (
(AgentInteraction.workspace_id, event.workspace_id),
(AgentInteraction.bot_id, event.bot_id),
(AgentInteraction.conversation_id, conversation_id),
(AgentInteraction.thread_id, event.thread_id),
(AgentInteraction.actor_id, event.actor.actor_id if event.actor else None),
):
conditions.append(column.is_(None) if value is None else column == value)
async with self._session_factory() as session:
result = await session.execute(sqlalchemy.select(AgentInteraction).where(*conditions))
records = [self._to_dict(row) for row in result.scalars()]
matches = [record for record in records if record['submission'] == submission]
return matches[0] if len(matches) == 1 else None
async def get_request(self, run_id: str, interaction_id: str) -> dict[str, typing.Any] | None:
"""Return a request by the runner-visible identity."""
async with self._session_factory() as session:
@@ -283,7 +346,7 @@ class InteractionStore:
if expires_at is not None and expires_at <= now:
row.status = 'expired'
row.status_reason = 'interaction expired before submission'
row.updated_at = now
row.updated_at = _db_datetime(now)
await session.commit()
raise InteractionExpiredError('Interaction has expired')
@@ -297,9 +360,9 @@ class InteractionStore:
)
.values(
status='submitted',
submitted_at=submission_time,
submitted_at=_db_datetime(submission_time),
submission_json=_json_dumps(submission),
updated_at=now,
updated_at=_db_datetime(now),
)
)
if update_result.rowcount != 1:
@@ -323,7 +386,7 @@ class InteractionStore:
AgentInteraction.interaction_id == interaction_id,
AgentInteraction.status == 'pending',
)
.values(status='delivery_failed', status_reason=reason, updated_at=now)
.values(status='delivery_failed', status_reason=reason, updated_at=_db_datetime(now))
)
await session.commit()
return result.rowcount == 1
@@ -337,12 +400,12 @@ class InteractionStore:
.where(
AgentInteraction.status == 'pending',
AgentInteraction.expires_at.is_not(None),
AgentInteraction.expires_at <= cutoff,
AgentInteraction.expires_at <= _db_datetime(cutoff),
)
.values(
status='expired',
status_reason='interaction expired',
updated_at=cutoff,
updated_at=_db_datetime(cutoff),
)
)
await session.commit()
+34 -49
View File
@@ -23,7 +23,6 @@ from .execution_context import (
append_mcp_resource_context_to_event,
build_mcp_resource_context_addition,
build_execution_query,
prepare_box_scope,
prepare_execution_query,
project_mcp_resource_config,
)
@@ -121,6 +120,16 @@ class AgentRunOrchestrator:
project_mcp_resource_config(execution_query, binding.runner_config)
object.__setattr__(execution_query, '_execution_context', execution_context)
if event.event_type == 'interaction.submitted' and binding.runner_config.get('user-id-source') in (
'legacy-session',
'legacy-bot',
):
await self.interaction_manager.restore_legacy_identity(event, binding)
from ...box.runner import prepare_input_files
event = event.model_copy(deep=True)
prepare_input_files(execution_query, event.input)
execution_event = event
resource_addition = await build_mcp_resource_context_addition(self.ap, execution_query)
if resource_addition:
@@ -159,6 +168,12 @@ class AgentRunOrchestrator:
str(skill['skill_name']) for skill in resources.get('skills', []) if skill.get('skill_name')
]
prepare_execution_query(execution_query, event, authorized_skill_names)
context['variables'] = {
key: value
for key, value in (execution_query.variables or {}).items()
if isinstance(key, str) and not key.startswith('_') and isinstance(value, (str, int, float, bool))
}
context['variables']['query_id'] = execution_query.query_id
state_context = build_state_context(event, binding, descriptor)
run_id = context['run_id']
@@ -356,6 +371,17 @@ class AgentRunOrchestrator:
assistant_transcript_written = True
result = await self.result_normalizer.normalize(result_dict, descriptor)
file_ids = result_dict.get('data', {}).get('file_ids', [])
if file_ids:
if result_type != 'message.completed' or result is None:
raise ValueError('Files must be attached to a completed message')
if binding.processor_type != 'pipeline':
raise ValueError('Agent files must be sent explicitly through the reply API')
from ...box.runner import exported_message, binding_for
async with binding_for(execution_query).lock:
result.attachments = exported_message(execution_query, file_ids, consume=True)
if result is not None:
yield result
@@ -387,6 +413,9 @@ class AgentRunOrchestrator:
)
raise
finally:
binding_box = getattr(execution_query, '_box_binding', None)
if binding_box is not None and binding_box.run_id == run_id:
object.__delattr__(execution_query, '_box_binding')
session = await self._session_registry.unregister(run_id)
await reply_streams.close()
pending_steering = session.get('steering_queue', []) if session else []
@@ -412,14 +441,12 @@ class AgentRunOrchestrator:
plan = self.query_bridge.build_plan(query)
adapter_context = dict(plan.adapter_context)
adapter_context['_query'] = query
import copy
adapter_context['_pipeline_expected_config'] = copy.deepcopy(query.pipeline_config)
adapter_context['_pipeline_conversation'] = getattr(getattr(query, 'session', None), 'using_conversation', None)
adapter_context['_execution_context'] = get_query_execution_context(query)
# Inbound files and subsequent runner tools must share one Host scope.
prepare_box_scope(query, plan.event)
# Materialize inbound attachments into sandbox before running
await self._materialize_inbound_attachments(query, plan.event)
async with contextlib.aclosing(
self.run(
plan.event,
@@ -431,48 +458,6 @@ class AgentRunOrchestrator:
async for result in results:
yield result
async def _materialize_inbound_attachments(
self,
query: pipeline_query.Query,
event: AgentEventEnvelope,
) -> None:
"""Persist inbound attachments into the sandbox and update event.input.attachments.
No-op when the box service is unavailable or there are no attachments.
On success, updates each attachment in event.input.attachments with the
sandbox path so runners can tell the model where to find the files.
"""
box_service = getattr(self.ap, 'box_service', None)
if box_service is None or not getattr(box_service, 'available', False):
return
try:
materialized = await box_service.materialize_inbound_attachments(query)
except Exception as e:
# Never break the chat turn over attachment IO
self.ap.logger.warning(f'Inbound attachment materialization failed: {e}')
return
if not materialized:
return
# Build a lookup by name for matching
materialized_by_name = {att.get('name'): att for att in materialized if att.get('name')}
# Update event.input.attachments with sandbox paths
if event.input and event.input.attachments:
for attachment in event.input.attachments:
name = attachment.name
if name and name in materialized_by_name:
mat = materialized_by_name[name]
# Update the attachment with sandbox path
attachment.path = mat.get('path')
attachment.size = mat.get('size') or attachment.size
attachment.mime_type = attachment.mime_type or mat.get('mime_type')
# Store materialized descriptors in query variables for downstream use
query.variables['_sandbox_inbound_attachments'] = materialized
def resolve_runner_id_for_telemetry(self, query: pipeline_query.Query) -> str | None:
"""Resolve runner ID for telemetry/logging without full execution."""
return self.query_bridge.resolve_runner_id_for_telemetry(query)
@@ -25,6 +25,7 @@ from langbot_plugin.api.entities.builtin.runner.delivery import DeliveryContext
from .host_models import (
AgentConfig,
AgentEventEnvelope,
LegacyRunnerIdentity,
StatePolicy,
DeliveryPolicy,
)
@@ -100,6 +101,24 @@ class QueryEntryAdapter:
delivery=delivery,
raw_ref=raw_ref,
data=event.data,
legacy_identity=cls._build_legacy_identity(query, execution_context.workspace_uuid),
)
@staticmethod
def _build_legacy_identity(query, workspace_id: str) -> LegacyRunnerIdentity:
def exact(value):
value = getattr(value, 'value', value)
return value if isinstance(value, str) and value else None
session = getattr(query, 'session', None)
return LegacyRunnerIdentity(
workspace_id=workspace_id,
bot_id=exact(getattr(query, 'bot_uuid', None)),
pipeline_id=exact(getattr(query, 'pipeline_uuid', None)),
query_launcher_type=exact(getattr(query, 'launcher_type', None)),
query_launcher_id=exact(getattr(query, 'launcher_id', None)),
session_launcher_type=exact(getattr(session, 'launcher_type', None)),
session_launcher_id=exact(getattr(session, 'launcher_id', None)),
)
@classmethod
@@ -83,6 +83,7 @@ class AgentRunJournal:
run_id=context['run_id'],
event_id=event.event_id,
binding_id=binding.binding_id,
agent_id=binding.agent_id,
runner_id=descriptor.id,
conversation_id=event.conversation_id,
thread_id=event.thread_id,
@@ -100,6 +101,19 @@ class AgentRunJournal:
if binding.processor_type == 'event_processor'
else {}
),
**(
{
**({'input_event': event.data} if event.event_type != 'message.received' else {}),
'input': {
'text': context.get('input', {}).get('text', ''),
'contents': self._sanitize_contents(context.get('input', {}).get('contents', [])),
'attachments': self._sanitize_attachments(context.get('input', {}).get('attachments', [])),
},
'delivery': event.delivery.model_dump(mode='json'),
}
if binding.processor_type == 'agent'
else {}
),
},
)
@@ -636,6 +636,7 @@ class RunLedgerStore:
strict_thread: bool = False,
runner_id: str | None = None,
binding_id: str | None = None,
agent_id: str | None = None,
) -> tuple[list[dict[str, typing.Any]], int | None, bool, int]:
"""Page runs by scope.
@@ -643,9 +644,21 @@ class RunLedgerStore:
Tuple of (items, next_cursor, has_more, total_count).
"""
limit = min(max(int(limit), 1), 100)
agent_filter = sqlalchemy.or_(
AgentRun.agent_id == agent_id,
sqlalchemy.and_(
AgentRun.agent_id.is_(None),
sqlalchemy.or_(
AgentRun.binding_id.startswith(f'agent_{agent_id}_', autoescape=True),
AgentRun.binding_id.startswith(f'debug:{agent_id}:', autoescape=True),
),
),
)
async with self._session_factory() as session:
# First get total count
count_query = sqlalchemy.select(sqlalchemy.func.count(AgentRun.id))
if agent_id is not None:
count_query = count_query.where(agent_filter)
if conversation_id is not None:
count_query = count_query.where(AgentRun.conversation_id == conversation_id)
if statuses:
@@ -660,6 +673,8 @@ class RunLedgerStore:
# Then get items
query = sqlalchemy.select(AgentRun)
if agent_id is not None:
query = query.where(agent_filter)
if conversation_id is not None:
query = query.where(AgentRun.conversation_id == conversation_id)
if statuses:
@@ -15,6 +15,7 @@ from ....workspace.collaboration import MembershipPermissionError, WorkspaceColl
from ....workspace.errors import WorkspaceNotFoundError
from ....cloud.entitlements import EntitlementUnavailableError
from ....core.errors import TaskCapacityError
from ....provider.modelmgr.codex_errors import CodexProviderError
from ..authz import (
AuthenticationDeniedError,
AuthorizationError,
@@ -249,6 +250,8 @@ class RouterGroup(abc.ABC):
return await f(*args, **kwargs)
except Exception as e: # 自动 500
if isinstance(e, CodexProviderError):
return self.http_status(e.status_code, e.error_code, str(e))
if isinstance(e, AuthorizationError):
return self.http_status(e.status_code, e.error_code, str(e))
if isinstance(e, WorkspaceNotFoundError):
@@ -47,6 +47,21 @@ class FilesRouterGroup(group.RouterGroup):
return quart.Response(image_bytes, mimetype=mime_type)
@self.route(
'/media/<filename>',
methods=['GET'],
auth_type=group.AuthType.NONE,
)
async def get_media_file(filename: str) -> quart.Response:
media = await self.ap.storage_mgr.media_cache.get_media(filename)
if media is None:
return quart.Response('Media not found or expired', status=404)
media_bytes, mime_type = media
headers = {
'Cache-Control': 'public, max-age=2592000, immutable',
}
return quart.Response(media_bytes, mimetype=mime_type, headers=headers)
@self.route(
'/images',
methods=['POST'],
@@ -5,6 +5,7 @@ import quart
from ...authz import Permission
from ...context import RequestContext
from ...service.monitoring_traffic import get_traffic_series
from .. import group
@@ -218,6 +219,7 @@ class MonitoringRouterGroup(group.RouterGroup):
pipeline_ids = quart.request.args.getlist('pipelineId')
start_time_str = quart.request.args.get('startTime')
end_time_str = quart.request.args.get('endTime')
user_query = quart.request.args.get('userQuery')
is_active_str = quart.request.args.get('isActive')
limit = int(quart.request.args.get('limit', 100))
offset = int(quart.request.args.get('offset', 0))
@@ -237,6 +239,7 @@ class MonitoringRouterGroup(group.RouterGroup):
pipeline_ids=pipeline_ids if pipeline_ids else None,
start_time=start_time,
end_time=end_time,
user_query=user_query,
is_active=is_active,
limit=limit,
offset=offset,
@@ -375,6 +378,14 @@ class MonitoringRouterGroup(group.RouterGroup):
return self.success(
data={
'traffic': await get_traffic_series(
self.ap,
request_context,
bot_ids=bot_ids or None,
pipeline_ids=pipeline_ids or None,
start_time=start_time,
end_time=end_time,
),
'overview': overview,
'messages': messages,
'llmCalls': llm_calls,
@@ -396,7 +407,15 @@ class MonitoringRouterGroup(group.RouterGroup):
@self.route('/sessions/<session_id>/analysis', methods=['GET'], permission=Permission.RESOURCE_VIEW)
async def get_session_analysis(session_id: str, request_context: RequestContext) -> str:
"""Get detailed analysis for a specific session"""
analysis = await self.ap.monitoring_service.get_session_analysis(request_context, session_id)
start_time = parse_iso_datetime(quart.request.args.get('startTime'))
end_time = parse_iso_datetime(quart.request.args.get('endTime'))
analysis = await self.ap.monitoring_service.get_session_analysis(
request_context,
session_id,
start_time=start_time,
end_time=end_time,
bot_id=quart.request.args.get('botId'),
)
# Always return success with the analysis data
# The frontend will handle the 'found: false' case
@@ -199,7 +199,9 @@ class EmbedRouterGroup(group.RouterGroup):
base_url = quart.request.host_url.rstrip('/')
webhook_prefix = self.ap.instance_config.data.get('api', {}).get('webhook_prefix', '')
if webhook_prefix:
# The wizard previews the currently connected backend, which can
# differ from the public address used by external website embeds.
if webhook_prefix and quart.request.args.get('preview') != 'wizard':
base_url = webhook_prefix.rstrip('/')
if not re.match(r'^https?://[a-zA-Z0-9._:/-]+$', base_url):
@@ -0,0 +1,33 @@
"""User-confirmed migration only; deliberately not API-key/MCP accessible."""
import quart
from ... import group
from ....authz import Permission
from ....context import RequestContext
from ....service.pipeline_migration import PipelineMigrationService, MigrationError
@group.group_class('pipeline_migration', '/api/v1/pipelines/_/migration')
class PipelineMigrationRouterGroup(group.RouterGroup):
async def initialize(self):
self.service = PipelineMigrationService(self.ap)
@self.route(
'/preview', methods=['GET'], auth_type=group.AuthType.USER_TOKEN, permission=Permission.RESOURCE_VIEW
)
async def preview(request_context: RequestContext):
try:
return self.success(data=await self.service.preview(request_context))
except MigrationError as exc:
return self.http_status(exc.status_code, -1, exc.code)
@self.route(
'/execute', methods=['POST'], auth_type=group.AuthType.USER_TOKEN, permission=Permission.RESOURCE_MANAGE
)
async def execute(request_context: RequestContext):
try:
body = await quart.request.get_json(silent=True)
return self.success(data=await self.service.execute(request_context, body))
except MigrationError as exc:
return self.http_status(exc.status_code, -1, exc.code)
@@ -4,6 +4,7 @@ from langbot.pkg.provider.modelmgr import errors as provider_errors
from ....authz import Permission, has_permission
from ....context import RequestContext
from ... import group
from .query import resolve_include_secret
MODEL_TEST_ERROR_CODE = 'model_test_failed'
@@ -20,7 +21,12 @@ class LLMModelsRouterGroup(group.RouterGroup):
)
async def _(request_context: RequestContext) -> str:
provider_uuid = quart.request.args.get('provider_uuid')
include_secret = has_permission(request_context, Permission.PROVIDER_SECRET_MANAGE)
include_secret, error = resolve_include_secret(
quart.request.args.get('include_secret'),
permitted=has_permission(request_context, Permission.PROVIDER_SECRET_MANAGE),
)
if error:
return self.http_status(400, -1, error)
if provider_uuid:
models = await self.ap.llm_model_service.get_llm_models_by_provider(
request_context,
@@ -57,10 +63,16 @@ class LLMModelsRouterGroup(group.RouterGroup):
permission=Permission.RESOURCE_VIEW,
)
async def _(model_uuid: str, request_context: RequestContext) -> str:
include_secret, error = resolve_include_secret(
quart.request.args.get('include_secret'),
permitted=has_permission(request_context, Permission.PROVIDER_SECRET_MANAGE),
)
if error:
return self.http_status(400, -1, error)
model = await self.ap.llm_model_service.get_llm_model(
request_context,
model_uuid,
include_secret=has_permission(request_context, Permission.PROVIDER_SECRET_MANAGE),
include_secret=include_secret,
)
if model is None:
return self.http_status(404, -1, 'model not found')
@@ -106,7 +118,7 @@ class LLMModelsRouterGroup(group.RouterGroup):
model_uuid,
await quart.request.json,
)
except (provider_errors.RequesterError, ValueError) as exc:
except provider_errors.RequesterError as exc:
return self.http_status(400, MODEL_TEST_ERROR_CODE, str(exc))
return self.success()
@@ -122,7 +134,12 @@ class EmbeddingModelsRouterGroup(group.RouterGroup):
)
async def _(request_context: RequestContext) -> str:
provider_uuid = quart.request.args.get('provider_uuid')
include_secret = has_permission(request_context, Permission.PROVIDER_SECRET_MANAGE)
include_secret, error = resolve_include_secret(
quart.request.args.get('include_secret'),
permitted=has_permission(request_context, Permission.PROVIDER_SECRET_MANAGE),
)
if error:
return self.http_status(400, -1, error)
if provider_uuid:
models = await self.ap.embedding_models_service.get_embedding_models_by_provider(
request_context,
@@ -159,10 +176,16 @@ class EmbeddingModelsRouterGroup(group.RouterGroup):
permission=Permission.RESOURCE_VIEW,
)
async def _(model_uuid: str, request_context: RequestContext) -> str:
include_secret, error = resolve_include_secret(
quart.request.args.get('include_secret'),
permitted=has_permission(request_context, Permission.PROVIDER_SECRET_MANAGE),
)
if error:
return self.http_status(400, -1, error)
model = await self.ap.embedding_models_service.get_embedding_model(
request_context,
model_uuid,
include_secret=has_permission(request_context, Permission.PROVIDER_SECRET_MANAGE),
include_secret=include_secret,
)
if model is None:
return self.http_status(404, -1, 'model not found')
@@ -208,7 +231,7 @@ class EmbeddingModelsRouterGroup(group.RouterGroup):
model_uuid,
await quart.request.json,
)
except (provider_errors.RequesterError, ValueError) as exc:
except provider_errors.RequesterError as exc:
return self.http_status(400, MODEL_TEST_ERROR_CODE, str(exc))
return self.success()
@@ -224,7 +247,12 @@ class RerankModelsRouterGroup(group.RouterGroup):
)
async def _(request_context: RequestContext) -> str:
provider_uuid = quart.request.args.get('provider_uuid')
include_secret = has_permission(request_context, Permission.PROVIDER_SECRET_MANAGE)
include_secret, error = resolve_include_secret(
quart.request.args.get('include_secret'),
permitted=has_permission(request_context, Permission.PROVIDER_SECRET_MANAGE),
)
if error:
return self.http_status(400, -1, error)
if provider_uuid:
models = await self.ap.rerank_models_service.get_rerank_models_by_provider(
request_context,
@@ -261,10 +289,16 @@ class RerankModelsRouterGroup(group.RouterGroup):
permission=Permission.RESOURCE_VIEW,
)
async def _(model_uuid: str, request_context: RequestContext) -> str:
include_secret, error = resolve_include_secret(
quart.request.args.get('include_secret'),
permitted=has_permission(request_context, Permission.PROVIDER_SECRET_MANAGE),
)
if error:
return self.http_status(400, -1, error)
model = await self.ap.rerank_models_service.get_rerank_model(
request_context,
model_uuid,
include_secret=has_permission(request_context, Permission.PROVIDER_SECRET_MANAGE),
include_secret=include_secret,
)
if model is None:
return self.http_status(404, -1, 'model not found')
@@ -310,6 +344,6 @@ class RerankModelsRouterGroup(group.RouterGroup):
model_uuid,
await quart.request.json,
)
except (provider_errors.RequesterError, ValueError) as exc:
except provider_errors.RequesterError as exc:
return self.http_status(400, MODEL_TEST_ERROR_CODE, str(exc))
return self.success()
@@ -3,11 +3,86 @@ import quart
from ....authz import Permission, has_permission
from ....context import RequestContext
from ... import group
from .query import resolve_include_secret
@group.group_class('models/providers', '/api/v1/provider/providers')
class ModelProvidersRouterGroup(group.RouterGroup):
async def initialize(self) -> None:
# Subscription authorization is an interactive, browser-user-only surface.
@self.route(
'/<provider_uuid>/codex/status',
methods=['GET'],
auth_type=group.AuthType.USER_TOKEN,
permission=Permission.PROVIDER_SECRET_MANAGE,
)
async def codex_status(provider_uuid: str, request_context: RequestContext):
try:
return self.success(
data=await self.ap.provider_service.codex_auth.status(request_context, provider_uuid)
)
except ValueError as exc:
return self.http_status(400, -1, str(exc))
@self.route(
'/<provider_uuid>/codex/device',
methods=['POST'],
auth_type=group.AuthType.USER_TOKEN,
permission=Permission.PROVIDER_SECRET_MANAGE,
)
async def codex_device(provider_uuid: str, request_context: RequestContext):
try:
return self.success(
data=await self.ap.provider_service.codex_auth.start(request_context, provider_uuid)
)
except ValueError as exc:
return self.http_status(400, -1, str(exc))
@self.route(
'/<provider_uuid>/codex/device/poll',
methods=['POST'],
auth_type=group.AuthType.USER_TOKEN,
permission=Permission.PROVIDER_SECRET_MANAGE,
)
async def codex_poll(provider_uuid: str, request_context: RequestContext):
body = await quart.request.get_json()
if not isinstance(body, dict):
return self.http_status(400, -1, 'JSON object required')
try:
return self.success(
data=await self.ap.provider_service.codex_auth.poll(
request_context, provider_uuid, body.get('authorization_id')
)
)
except ValueError as exc:
return self.http_status(400, -1, str(exc))
@self.route(
'/<provider_uuid>/codex/auth',
methods=['DELETE'],
auth_type=group.AuthType.USER_TOKEN,
permission=Permission.PROVIDER_SECRET_MANAGE,
)
async def codex_disconnect(provider_uuid: str, request_context: RequestContext):
try:
await self.ap.provider_service.codex_auth.disconnect(request_context, provider_uuid)
return self.success()
except ValueError as exc:
return self.http_status(400, -1, str(exc))
@self.route(
'/<provider_uuid>/codex/device/<authorization_id>',
methods=['DELETE'],
auth_type=group.AuthType.USER_TOKEN,
permission=Permission.PROVIDER_SECRET_MANAGE,
)
async def codex_cancel(provider_uuid: str, authorization_id: str, request_context: RequestContext):
try:
await self.ap.provider_service.codex_auth.cancel(request_context, provider_uuid, authorization_id)
return self.success()
except ValueError as exc:
return self.http_status(400, -1, str(exc))
@self.route(
'',
methods=['GET'],
@@ -15,9 +90,15 @@ class ModelProvidersRouterGroup(group.RouterGroup):
permission=Permission.RESOURCE_VIEW,
)
async def _(request_context: RequestContext) -> str:
include_secret, error = resolve_include_secret(
quart.request.args.get('include_secret'),
permitted=has_permission(request_context, Permission.PROVIDER_SECRET_MANAGE),
)
if error:
return self.http_status(400, -1, error)
providers = await self.ap.provider_service.get_providers(
request_context,
include_secret=has_permission(request_context, Permission.PROVIDER_SECRET_MANAGE),
include_secret=include_secret,
)
for provider in providers:
counts = await self.ap.provider_service.get_provider_model_counts(request_context, provider['uuid'])
@@ -47,10 +128,16 @@ class ModelProvidersRouterGroup(group.RouterGroup):
permission=Permission.RESOURCE_VIEW,
)
async def _(provider_uuid: str, request_context: RequestContext) -> str:
include_secret, error = resolve_include_secret(
quart.request.args.get('include_secret'),
permitted=has_permission(request_context, Permission.PROVIDER_SECRET_MANAGE),
)
if error:
return self.http_status(400, -1, error)
provider = await self.ap.provider_service.get_provider(
request_context,
provider_uuid,
include_secret=has_permission(request_context, Permission.PROVIDER_SECRET_MANAGE),
include_secret=include_secret,
)
if provider is None:
return self.http_status(404, -1, 'provider not found')
@@ -82,7 +169,15 @@ class ModelProvidersRouterGroup(group.RouterGroup):
)
async def _(provider_uuid: str, request_context: RequestContext) -> str:
try:
await self.ap.provider_service.delete_provider(request_context, provider_uuid)
cascade_values = quart.request.args.getlist('cascade')
if cascade_values:
if len(cascade_values) != 1 or cascade_values[0] not in ('true', 'false'):
return self.http_status(400, -1, 'cascade must be a single true or false value')
await self.ap.provider_service.delete_provider(
request_context, provider_uuid, cascade=cascade_values[0] == 'true'
)
else:
await self.ap.provider_service.delete_provider(request_context, provider_uuid)
return self.success()
except ValueError as e:
return self.http_status(400, -1, str(e))
@@ -0,0 +1,15 @@
from __future__ import annotations
def resolve_include_secret(raw_value: str | None, *, permitted: bool) -> tuple[bool, str | None]:
"""Resolve the optional secret projection query parameter."""
if raw_value is None:
return permitted, None
value = raw_value.strip().lower()
if value == 'false':
return False, None
if value == 'true':
return permitted, None
return False, 'include_secret must be either true or false'
@@ -7,14 +7,116 @@ from .. import group
from .....utils import constants
from .....entity.persistence.metadata import WorkspaceMetadata
from ...authz import Permission
from ...context import RequestContext
from ...context import PrincipalType, RequestContext
from .....provider.tools.loaders.mcp_policy import stdio_mcp_enabled
from .....workspace.invitation_delivery import InvitationDeliveryService
SYSTEM_CAPABILITY_OPERATIONS = (
'bot.list',
'bot.get',
'bot.create',
'bot.update',
'bot.delete',
'pipeline.list',
'pipeline.get',
'pipeline.create',
'pipeline.update',
'pipeline.delete',
'pipeline.copy',
'task.list',
'task.get',
'knowledge_base.list',
'knowledge_base.get',
'knowledge_base.create',
'knowledge_base.update',
'knowledge_base.delete',
'knowledge_base.file.list',
'knowledge_base.file.store',
'knowledge_base.file.delete',
'knowledge_base.retrieve',
'file.document.upload',
'plugin.install.github',
'plugin.install.marketplace',
'plugin.install.local',
'plugin.upgrade',
'plugin.get',
'plugin.list',
'plugin.config.get',
'plugin.config.update',
'plugin.logs',
'plugin.delete',
'provider.list',
'provider.get',
'provider.create',
'provider.update',
'provider.delete',
'provider.scan_models',
'model.llm.list',
'model.llm.get',
'model.llm.create',
'model.llm.update',
'model.llm.delete',
'model.llm.test',
'model.embedding.list',
'model.embedding.get',
'model.embedding.create',
'model.embedding.update',
'model.embedding.delete',
'model.embedding.test',
'model.rerank.list',
'model.rerank.get',
'model.rerank.create',
'model.rerank.update',
'model.rerank.delete',
'model.rerank.test',
'skill.list',
'skill.get',
'skill.create',
'skill.update',
'skill.delete',
'skill.files.list',
'skill.files.read',
'skill.files.write',
'skill.preview',
'skill.install.github',
'skill.install.upload',
'mcp_server.list',
'mcp_server.get',
'mcp_server.create',
'mcp_server.update',
'mcp_server.delete',
'mcp_server.resources',
'mcp_server.resource_templates',
'mcp_server.resource_read',
'mcp_server.logs',
'mcp_server.test',
)
@group.group_class('system', '/api/v1/system')
class SystemRouterGroup(group.RouterGroup):
async def initialize(self) -> None:
@self.route('/context', methods=['GET'], auth_type=group.AuthType.API_KEY)
async def _(request_context: RequestContext) -> str:
return self.success(
data={
'instance_uuid': request_context.instance_uuid,
'workspace_uuid': request_context.workspace_uuid,
'api_key_id': request_context.principal.api_key_uuid,
'permissions': sorted(request_context.workspace.permissions),
}
)
@self.route('/capabilities', methods=['GET'], auth_type=group.AuthType.API_KEY)
async def _() -> str:
return self.success(
data={
'schema_version': 1,
'operations': {operation: {'supported': True} for operation in SYSTEM_CAPABILITY_OPERATIONS},
}
)
@self.route('/info', methods=['GET'], auth_type=group.AuthType.NONE)
async def _() -> str:
# Read wizard_status and wizard_progress from metadata table
@@ -238,7 +340,7 @@ class SystemRouterGroup(group.RouterGroup):
@self.route(
'/tasks',
methods=['GET'],
auth_type=group.AuthType.USER_TOKEN,
auth_type=group.AuthType.USER_TOKEN_OR_API_KEY,
permission=Permission.RESOURCE_VIEW,
)
async def _(request_context: RequestContext) -> str:
@@ -257,18 +359,23 @@ class SystemRouterGroup(group.RouterGroup):
instance_uuid=request_context.instance_uuid,
workspace_uuid=request_context.workspace_uuid,
placement_generation=request_context.placement_generation,
public=request_context.principal.principal_type == PrincipalType.API_KEY,
)
)
@self.route(
'/tasks/<task_id>',
methods=['GET'],
auth_type=group.AuthType.USER_TOKEN,
auth_type=group.AuthType.USER_TOKEN_OR_API_KEY,
permission=Permission.RESOURCE_VIEW,
)
async def _(task_id: str, request_context: RequestContext) -> str:
try:
task_index = int(task_id)
except (TypeError, ValueError):
return self.http_status(404, 404, 'Task not found')
task = self.ap.task_mgr.get_task_by_id(
int(task_id),
task_index,
instance_uuid=request_context.instance_uuid,
workspace_uuid=request_context.workspace_uuid,
placement_generation=request_context.placement_generation,
@@ -277,6 +384,8 @@ class SystemRouterGroup(group.RouterGroup):
if task is None:
return self.http_status(404, 404, 'Task not found')
if request_context.principal.principal_type == PrincipalType.API_KEY:
return self.success(data=task.to_public_dict())
return self.success(data=task.to_dict())
@self.route(
@@ -1,7 +1,12 @@
from __future__ import annotations
import quart
import argon2
import asyncio
import datetime
import hmac
import time
import typing
import uuid
from urllib.parse import parse_qs, urlsplit
@@ -11,6 +16,33 @@ from ...context import RequestContext
from .....cloud.launch import SpaceLaunchError
from ...service.user import ControlPlaneDirectoryRequiredError, PublicRegistrationClosedError
# Fixed-window admission quota for the unauthenticated reset-password endpoint (#2392).
# The admission check and slot bump share ONE synchronous critical section with no await
# points, so concurrent bursts within a single event loop cannot slip past accounting.
# Every admitted attempt consumes quota (regardless of success), which throttles both the
# legacy 24-bit keyspace exhaustion and brute-force on modern high-entropy keys.
# NOTE: this state is process-local; multi-worker deployments need a shared limiter upstream.
_MAX_RESET_ATTEMPTS_PER_WINDOW = 5
_RESET_WINDOW_SECONDS = 15 * 60
_reset_password_state: dict = {'window_started_at': 0.0, 'attempts': 0}
def _admit_reset_attempt(now: float) -> bool:
"""Atomically reserve one reset-password admission slot.
Must stay await-free: running to completion without suspension makes the
check-and-increment atomic under the single-threaded event loop.
"""
st = _reset_password_state
if now - st['window_started_at'] >= _RESET_WINDOW_SECONDS:
st['window_started_at'] = now
st['attempts'] = 0
if st['attempts'] >= _MAX_RESET_ATTEMPTS_PER_WINDOW:
return False
st['attempts'] += 1
return True
@group.group_class('user', '/api/v1/user')
class UserRouterGroup(group.RouterGroup):
@@ -35,6 +67,22 @@ class UserRouterGroup(group.RouterGroup):
return redirect_uri
def _extract_origin_and_rp_id(self, json_data: dict[str, typing.Any] | None = None) -> tuple[str, str]:
origin = ''
if json_data and isinstance(json_data, dict):
origin = json_data.get('origin', '')
if not origin:
origin = quart.request.headers.get('Origin', '')
if not origin:
origin = quart.request.headers.get('Referer', '')
if not origin:
origin = quart.request.url_root.rstrip('/')
parsed = urlsplit(origin)
rp_id = parsed.hostname or 'localhost'
clean_origin = f'{parsed.scheme}://{parsed.netloc}' if parsed.scheme and parsed.netloc else origin.rstrip('/')
return clean_origin, rp_id
async def initialize(self) -> None:
@self.route('/init', methods=['GET', 'POST'], auth_type=group.AuthType.NONE)
async def _() -> str:
@@ -81,6 +129,12 @@ class UserRouterGroup(group.RouterGroup):
@self.route('/reset-password', methods=['POST'], auth_type=group.AuthType.NONE)
async def _() -> str:
# Admit (or reject) BEFORE touching the body or any service call (#2392):
# rejecting requests never reach the slow path, and quota accounting happens
# synchronously at entry, closing the post-await race of burst requests.
if not _admit_reset_attempt(time.monotonic()):
return self.http_status(429, -1, 'Too many attempts, try again later')
json_data = await quart.request.json
user_email = json_data['user']
@@ -98,7 +152,18 @@ class UserRouterGroup(group.RouterGroup):
if user_obj is None:
return self.http_status(400, -1, 'User not found')
if recovery_key != self.ap.instance_config.data['system']['recovery_key']:
stored_key = self.ap.instance_config.data['system']['recovery_key']
try:
key_matches = (
isinstance(recovery_key, str)
and isinstance(stored_key, str)
and hmac.compare_digest(recovery_key.encode(), stored_key.encode())
)
except UnicodeEncodeError:
# JSON can contain lone surrogates, which are not valid UTF-8.
key_matches = False
if not key_matches:
return self.http_status(403, -1, 'Invalid recovery key')
await self.ap.user_service.reset_password(user_email, new_password)
@@ -186,6 +251,9 @@ class UserRouterGroup(group.RouterGroup):
json_data = await quart.request.json
code = json_data.get('code')
state = json_data.get('state')
redirect_uri = json_data.get('redirect_uri') or (
quart.request.url_root.rstrip('/') + '/auth/space/callback'
)
launch_assertion = json_data.get('launch_assertion')
workspace_uuid = json_data.get('workspace_uuid')
@@ -199,8 +267,11 @@ class UserRouterGroup(group.RouterGroup):
return self.fail(1, 'Missing authorization code')
if not state:
return self.fail(1, 'Missing state parameter')
if not str(code).startswith('v4_'):
return self.fail(1, 'Unsupported Space OAuth code contract')
try:
redirect_uri = self._validate_space_redirect_uri(str(redirect_uri), bind=False)
consumed_state = await self.ap.user_service.consume_space_oauth_state_details(state, 'login')
# Exchange code for tokens
launch_workspace_uuid = consumed_state.launch_workspace_uuid
@@ -218,24 +289,36 @@ class UserRouterGroup(group.RouterGroup):
code,
workspace_uuids,
workspace_created_ats,
redirect_uri=redirect_uri,
)
access_token = token_data.get('access_token')
refresh_token = token_data.get('refresh_token')
expires_in = token_data.get('expires_in', 0)
cloud_workspace_uuid = token_data.get('cloud_workspace_uuid')
if not access_token:
return self.fail(1, 'Failed to get access token from Space')
# Authenticate and create/update local user
cloud_mode = getattr(getattr(self.ap, 'deployment', None), 'mode', 'oss') == 'cloud'
if cloud_mode and launch_workspace_uuid and launch_workspace_uuid != cloud_workspace_uuid:
return self.fail(1, 'Space OAuth Workspace binding mismatch')
target_workspace_uuid = launch_workspace_uuid or cloud_workspace_uuid
if cloud_mode:
if not target_workspace_uuid:
return self.fail(1, 'Space OAuth response is missing the Cloud Workspace binding')
await self.ap.directory_projection_service.reconcile_workspaces((target_workspace_uuid,))
# Authenticate only after the signed, exact Workspace delta has
# established the Account and membership runtime shadow rows.
jwt_token, user_obj = await self.ap.user_service.authenticate_space_user(
access_token, refresh_token, expires_in
)
if launch_workspace_uuid:
if target_workspace_uuid:
try:
access = await self.ap.workspace_collaboration_service.resolve_account_workspace(
user_obj.uuid,
launch_workspace_uuid,
target_workspace_uuid,
)
except Exception:
self.ap.logger.warning('Rejected Space OAuth launch for unauthorized Workspace')
@@ -323,6 +406,8 @@ class UserRouterGroup(group.RouterGroup):
capabilities['password_login_enabled'] = False
capabilities['authenticated_invitation_acceptance_enabled'] = cloud_mode
capabilities['invitation_registration_enabled'] = not cloud_mode
capabilities['passkey_login_enabled'] = True
capabilities['passkey_supported'] = True
return self.success(data={'initialized': True, **capabilities})
@self.route('/set-password', methods=['POST'], auth_type=group.AuthType.USER_TOKEN)
@@ -367,12 +452,17 @@ class UserRouterGroup(group.RouterGroup):
json_data = await quart.request.json
code = json_data.get('code')
state = json_data.get('state')
redirect_uri = json_data.get('redirect_uri') or (
quart.request.url_root.rstrip('/') + '/auth/space/callback?mode=bind'
)
if not code:
return self.http_status(400, -1, 'Missing authorization code')
if not state:
return self.http_status(400, -1, 'Missing state parameter')
if not str(code).startswith('v4_'):
return self.http_status(400, -1, 'Unsupported Space OAuth code contract')
try:
user_obj = await self.ap.user_service.consume_space_oauth_state(state, 'bind')
@@ -385,7 +475,10 @@ class UserRouterGroup(group.RouterGroup):
return self.http_status(400, -1, 'Only local accounts can bind to Space')
try:
updated_user = await self.ap.user_service.bind_space_account(user_obj.user, code)
redirect_uri = self._validate_space_redirect_uri(str(redirect_uri), bind=True)
updated_user = await self.ap.user_service.bind_space_account(
user_obj.user, code, redirect_uri=redirect_uri
)
jwt_token = await self.ap.user_service.generate_jwt_token(updated_user)
return self.success(
data={
@@ -405,6 +498,182 @@ class UserRouterGroup(group.RouterGroup):
except Exception:
raise
@self.route('/passkey/register/options', methods=['POST'], auth_type=group.AuthType.USER_TOKEN)
async def _(user_email: str) -> str:
"""Generate WebAuthn registration options for current account."""
allow_modify_login_info = self.ap.instance_config.data.get('system', {}).get(
'allow_modify_login_info', True
)
if not allow_modify_login_info:
return self.http_status(403, -1, 'Modifying login info is disabled')
user_obj = await self.ap.user_service.get_user_by_email(user_email)
if user_obj is None:
return self.http_status(404, -1, 'User not found')
json_data = (await quart.request.json) or {}
origin, rp_id = self._extract_origin_and_rp_id(json_data)
try:
options, challenge_token = await self.ap.user_service.generate_passkey_registration_options(
account_uuid=user_obj.uuid,
rp_id=rp_id,
origin=origin,
rp_name='LangBot',
)
return self.success(data={'options': options, 'challenge_token': challenge_token})
except Exception as e:
return self.fail(1, str(e))
@self.route('/passkey/register/verify', methods=['POST'], auth_type=group.AuthType.USER_TOKEN)
async def _(user_email: str) -> str:
"""Verify WebAuthn registration response and save credential."""
allow_modify_login_info = self.ap.instance_config.data.get('system', {}).get(
'allow_modify_login_info', True
)
if not allow_modify_login_info:
return self.http_status(403, -1, 'Modifying login info is disabled')
user_obj = await self.ap.user_service.get_user_by_email(user_email)
if user_obj is None:
return self.http_status(404, -1, 'User not found')
json_data = await quart.request.json
challenge_token = json_data.get('challenge_token')
credential = json_data.get('credential') or json_data.get('response')
name = json_data.get('name')
if not challenge_token or not credential:
return self.fail(1, 'Missing challenge_token or credential')
try:
cred = await self.ap.user_service.verify_and_save_passkey_registration(
challenge_token=challenge_token,
credential_data=credential,
name=name,
)
return self.success(
data={
'uuid': cred.uuid,
'name': cred.name,
'created_at': cred.created_at.isoformat() if cred.created_at else None,
}
)
except Exception as e:
return self.fail(1, str(e))
@self.route('/passkey/auth/options', methods=['POST'], auth_type=group.AuthType.NONE)
async def _() -> str:
"""Generate WebAuthn authentication options for passkey login."""
json_data = (await quart.request.json) or {}
email = json_data.get('email')
origin, rp_id = self._extract_origin_and_rp_id(json_data)
try:
options, challenge_token = await self.ap.user_service.generate_passkey_authentication_options(
rp_id=rp_id,
origin=origin,
email=email,
)
return self.success(data={'options': options, 'challenge_token': challenge_token})
except Exception as e:
return self.fail(1, str(e))
@self.route('/passkey/auth/verify', methods=['POST'], auth_type=group.AuthType.NONE)
async def _() -> str:
"""Verify WebAuthn authentication response and log in."""
json_data = await quart.request.json
challenge_token = json_data.get('challenge_token')
credential = json_data.get('credential') or json_data.get('response')
if not challenge_token or not credential:
return self.fail(1, 'Missing challenge_token or credential')
try:
token, user_obj = await self.ap.user_service.verify_passkey_authentication(
challenge_token=challenge_token,
credential_data=credential,
)
return self.success(
data={
'token': token,
'user': user_obj.user,
}
)
except Exception as e:
return self.fail(1, str(e))
@self.route('/passkeys', methods=['GET'], auth_type=group.AuthType.USER_TOKEN)
async def _(user_email: str) -> str:
"""List registered passkeys for the current user."""
user_obj = await self.ap.user_service.get_user_by_email(user_email)
if user_obj is None:
return self.http_status(404, -1, 'User not found')
passkeys = await self.ap.user_service.get_user_passkeys(user_obj.uuid)
return self.success(
data=[
{
'uuid': pk.uuid,
'name': pk.name,
'aaguid': pk.aaguid,
'transports': pk.transports,
'backed_up': pk.backed_up,
'created_at': pk.created_at.isoformat() if pk.created_at else None,
'last_used_at': pk.last_used_at.isoformat() if pk.last_used_at else None,
}
for pk in passkeys
]
)
@self.route('/passkey/<passkey_uuid>', methods=['PATCH'], auth_type=group.AuthType.USER_TOKEN)
async def _(user_email: str, passkey_uuid: str) -> str:
"""Rename a registered passkey."""
allow_modify_login_info = self.ap.instance_config.data.get('system', {}).get(
'allow_modify_login_info', True
)
if not allow_modify_login_info:
return self.http_status(403, -1, 'Modifying login info is disabled')
user_obj = await self.ap.user_service.get_user_by_email(user_email)
if user_obj is None:
return self.http_status(404, -1, 'User not found')
json_data = await quart.request.json
name = (json_data.get('name') or '').strip()
if not name:
return self.fail(1, 'Passkey name cannot be empty')
updated = await self.ap.user_service.rename_user_passkey(
account_uuid=user_obj.uuid,
passkey_uuid=passkey_uuid,
new_name=name,
)
if not updated:
return self.http_status(404, -1, 'Passkey not found')
return self.success(data={'uuid': updated.uuid, 'name': updated.name})
@self.route('/passkey/<passkey_uuid>', methods=['DELETE'], auth_type=group.AuthType.USER_TOKEN)
async def _(user_email: str, passkey_uuid: str) -> str:
"""Delete/revoke a registered passkey."""
allow_modify_login_info = self.ap.instance_config.data.get('system', {}).get(
'allow_modify_login_info', True
)
if not allow_modify_login_info:
return self.http_status(403, -1, 'Modifying login info is disabled')
user_obj = await self.ap.user_service.get_user_by_email(user_email)
if user_obj is None:
return self.http_status(404, -1, 'User not found')
deleted = await self.ap.user_service.delete_user_passkey(
account_uuid=user_obj.uuid,
passkey_uuid=passkey_uuid,
)
if not deleted:
return self.http_status(404, -1, 'Passkey not found')
return self.success()
async def _handle_space_direct_launch(
self,
launch_assertion: str,
@@ -428,6 +697,10 @@ class UserRouterGroup(group.RouterGroup):
}
)
projection_service = self.ap.directory_projection_service
if projection_service is None:
raise SpaceLaunchError('Cloud directory projection is unavailable')
await projection_service.reconcile_workspaces((launch['workspace_uuid'],))
account = await self.ap.user_service.get_user_by_uuid(launch['account_uuid'])
if account is None:
raise SpaceLaunchError('Launch Account is not projected into Core')
+19 -7
View File
@@ -529,16 +529,20 @@ class AgentService:
return config, component_ref, descriptor.supported_event_patterns
async def get_processor_runs(self, context, processor_id, *, before_id=None):
"""Read only this Workspace's explicitly created processor instance."""
"""Read only this Workspace's Agent or plugin processor runs."""
from ....agent.runner.run_ledger_store import RunLedgerStore
processor = await self.get_agent(context, processor_id)
if processor is None or processor.get('kind') != AGENT_KIND_EVENT_PROCESSOR:
raise ValueError('Event processor not found')
if processor is None or processor.get('kind') not in {'agent', AGENT_KIND_EVENT_PROCESSOR}:
raise ValueError('Processor not found')
store = RunLedgerStore(self.ap.persistence_mgr.get_db_engine())
items, cursor, has_more, total = await store.list_runs(
workspace_id=require_workspace_uuid(context),
binding_id=f'event_processor:{processor_id}',
**(
{'binding_id': f'event_processor:{processor_id}'}
if processor.get('kind') == AGENT_KIND_EVENT_PROCESSOR
else {'agent_id': processor_id}
),
before_id=before_id,
)
return {'items': items, 'next_cursor': cursor, 'has_more': has_more, 'total': total}
@@ -548,14 +552,22 @@ class AgentService:
from ....agent.runner.run_ledger_store import RunLedgerStore
processor = await self.get_agent(context, processor_id)
if processor is None or processor.get('kind') != AGENT_KIND_EVENT_PROCESSOR:
raise ValueError('Event processor not found')
if processor is None or processor.get('kind') not in {'agent', AGENT_KIND_EVENT_PROCESSOR}:
raise ValueError('Processor not found')
store = RunLedgerStore(self.ap.persistence_mgr.get_db_engine())
run = await store.get_run(run_id)
if (
run is None
or run.get('workspace_id') != require_workspace_uuid(context)
or run.get('binding_id') != f'event_processor:{processor_id}'
or not (
run.get('binding_id') == f'event_processor:{processor_id}'
if processor.get('kind') == AGENT_KIND_EVENT_PROCESSOR
else run.get('agent_id') == processor_id
or (
run.get('agent_id') is None
and str(run.get('binding_id', '')).startswith((f'agent_{processor_id}_', f'debug:{processor_id}:'))
)
)
):
raise ValueError('Processor run not found')
items, next_cursor, _, has_more = await store.page_run_events(
@@ -80,16 +80,40 @@ class MaintenanceService:
DEFAULT_LOG_RETENTION_DAYS,
'storage.cleanup.log_retention_days',
)
media_cfg = self.ap.instance_config.data.get('storage', {}).get('media_cache', {})
media_retention_days = self._positive_int(
media_cfg.get('retention_days'),
30,
'storage.media_cache.retention_days',
)
media_max_size_mb = self._non_negative_int(
media_cfg.get('max_size_mb'),
0,
'storage.media_cache.max_size_mb',
)
media_cache = getattr(getattr(self.ap, 'storage_mgr', None), 'media_cache', None)
is_singleton = await self._is_oss_singleton(context)
media_cleanup = (
await media_cache.cleanup(
media_retention_days,
media_max_size_mb,
)
if media_cache is not None and is_singleton
else {}
)
return {
result = {
'uploaded_files': await self._cleanup_expired_uploaded_files(context, upload_retention_days),
'log_files': await asyncio.to_thread(
self._cleanup_expired_log_files,
log_retention_days,
)
if await self._is_oss_singleton(context)
if is_singleton
else 0,
}
if media_cache is not None and is_singleton:
result['media_files'] = media_cleanup.get('expired_deleted', 0) + media_cleanup.get('size_deleted', 0)
return result
async def get_storage_analysis(self, context: TenantContext) -> dict[str, Any]:
require_workspace_uuid(context)
@@ -573,6 +597,17 @@ class MaintenanceService:
count += len(files)
return count
def _non_negative_int(self, value: Any, default: int, name: str) -> int:
try:
parsed = int(value)
except (TypeError, ValueError):
self.ap.logger.warning(f'Invalid {name}: {value!r}, using {default}')
return default
if parsed < 0:
self.ap.logger.warning(f'{name} must be non-negative: {value!r}, using {default}')
return default
return parsed
def _positive_int(self, value: Any, default: int, name: str) -> int:
try:
parsed = int(value)
+16 -9
View File
@@ -446,15 +446,19 @@ class MCPService:
persisted_session = runtime_mcp_session
async def _refresh_and_report() -> None:
needs_start = persisted_session.status == MCPSessionStatus.ERROR or persisted_session.session is None
if needs_start:
await persisted_session.start()
else:
try:
await persisted_session.refresh()
except Exception:
try:
needs_start = (
persisted_session.status == MCPSessionStatus.ERROR or persisted_session.session is None
)
if needs_start:
await persisted_session.start()
ctx.metadata['runtime_info'] = persisted_session.get_runtime_info_dict()
else:
try:
await persisted_session.refresh()
except Exception:
await persisted_session.start()
finally:
ctx.metadata['runtime_info'] = persisted_session.get_runtime_info_dict()
coroutine = _refresh_and_report()
else:
@@ -471,8 +475,11 @@ class MCPService:
async def _run_and_cleanup() -> None:
try:
await test_session.start()
ctx.metadata['runtime_info'] = test_session.get_runtime_info_dict()
finally:
# start() raises for a failed connection. Preserve the
# terminal runtime state so the UI can render actionable
# failure phases such as OAuth-required.
ctx.metadata['runtime_info'] = test_session.get_runtime_info_dict()
try:
await test_session.shutdown()
except Exception as exc:
+112 -23
View File
@@ -1,5 +1,6 @@
from __future__ import annotations
import re
import uuid
import datetime
import functools
@@ -29,6 +30,19 @@ _DEFAULT_CLEANUP_BATCHES_PER_TABLE = 4
_HARD_MAX_CLEANUP_BATCHES_PER_TABLE = 100
def _normalize_user_id(value: str | int | None) -> str | None:
"""Convert numeric platform IDs before binding a VARCHAR with asyncpg.
Opaque string IDs (including whitespace and leading zeros) and missing
IDs must remain unchanged. Do not silently stringify unsupported objects.
"""
if value is None or isinstance(value, str):
return value
if isinstance(value, int) and not isinstance(value, bool):
return str(value)
raise TypeError('user_id must be a string, integer, or None')
def _workspace_transaction(method):
"""Run an explicit service entrypoint in one Workspace transaction."""
@@ -281,19 +295,21 @@ class MonitoringService:
for _batch_number in range(max_batches):
async def delete_batch() -> tuple[int, int]:
key_columns = list(model_cls.__table__.primary_key.columns)
select_result = await self.ap.persistence_mgr.execute_async(
sqlalchemy.select(pk_column)
sqlalchemy.select(*key_columns)
.where(model_cls.workspace_uuid == workspace_uuid, ts_column < cutoff)
.limit(batch_size)
)
pk_values = list(select_result.scalars().all())
pk_values = [tuple(row) for row in select_result.all()]
if not pk_values:
return 0, 0
delete_result = await self.ap.persistence_mgr.execute_async(
sqlalchemy.delete(model_cls).where(
model_cls.workspace_uuid == workspace_uuid,
pk_column.in_(pk_values),
sqlalchemy.tuple_(*key_columns).in_(pk_values),
ts_column < cutoff,
)
)
return len(pk_values), int(delete_result.rowcount or 0)
@@ -402,6 +418,32 @@ class MonitoringService:
# ========== Recording Methods ==========
def _sanitize_message_content(self, content: str) -> str:
"""Strip raw base64 data to protect database storage from unbounded bloating."""
if not content or len(content) < 10000 or (';base64,' not in content and 'data:image/' not in content):
return content
try:
data = json.loads(content)
def _strip_node(node):
if isinstance(node, list):
return [_strip_node(x) for x in node]
if isinstance(node, dict):
res = dict(node)
if res.get('type') == 'Image' and res.get('base64'):
res['base64'] = None
for k, v in list(res.items()):
if isinstance(v, (list, dict)):
res[k] = _strip_node(v)
return res
return node
return json.dumps(_strip_node(data), ensure_ascii=False)
except Exception:
return re.sub(
r'data:image/[a-zA-Z0-9.+_-]+;base64,[\sA-Za-z0-9+/=]{1000,}', '[base64 image omitted]', content
)
@_workspace_transaction
async def record_message(
self,
@@ -415,7 +457,7 @@ class MonitoringService:
status: str = 'success',
level: str = 'info',
platform: str | None = None,
user_id: str | None = None,
user_id: str | int | None = None,
user_name: str | None = None,
runner_name: str | None = None,
variables: str | None = None,
@@ -424,6 +466,7 @@ class MonitoringService:
"""Record a message"""
workspace_uuid = self._require_write_context(context)
message_id = str(uuid.uuid4())
message_content = self._sanitize_message_content(message_content)
message_data = {
'id': message_id,
'workspace_uuid': workspace_uuid,
@@ -437,7 +480,7 @@ class MonitoringService:
'status': status,
'level': level,
'platform': platform,
'user_id': user_id,
'user_id': _normalize_user_id(user_id),
'user_name': user_name,
'runner_name': runner_name,
'variables': variables,
@@ -610,7 +653,7 @@ class MonitoringService:
pipeline_id: str,
pipeline_name: str,
platform: str | None = None,
user_id: str | None = None,
user_id: str | int | None = None,
user_name: str | None = None,
) -> None:
"""Record a new session"""
@@ -622,17 +665,29 @@ class MonitoringService:
'bot_name': bot_name,
'pipeline_id': pipeline_id,
'pipeline_name': pipeline_name,
'message_count': 0,
'message_count': 1,
'start_time': datetime.datetime.now(datetime.timezone.utc).replace(tzinfo=None),
'last_activity': datetime.datetime.now(datetime.timezone.utc).replace(tzinfo=None),
'is_active': True,
'platform': platform,
'user_id': user_id,
'user_id': _normalize_user_id(user_id),
'user_name': user_name,
}
model = persistence_monitoring.MonitoringSession
dialect = self.ap.persistence_mgr.get_db_engine().dialect.name
insert = postgresql_dialect.insert if dialect == 'postgresql' else sqlite_dialect.insert
statement = insert(model).values(session_data)
await self.ap.persistence_mgr.execute_async(
sqlalchemy.insert(persistence_monitoring.MonitoringSession).values(session_data)
statement.on_conflict_do_update(
index_elements=['workspace_uuid', 'bot_id', 'session_id'],
set_={
'message_count': model.message_count + 1,
'last_activity': statement.excluded.last_activity,
'pipeline_id': statement.excluded.pipeline_id,
'pipeline_name': statement.excluded.pipeline_name,
},
)
)
@_workspace_transaction
@@ -642,6 +697,7 @@ class MonitoringService:
session_id: str,
pipeline_id: str | None = None,
pipeline_name: str | None = None,
bot_id: str | None = None,
) -> bool:
"""Update session last activity time and increment message count.
@@ -651,6 +707,9 @@ class MonitoringService:
True if session was found and updated, False if session doesn't exist.
"""
workspace_uuid = self._require_write_context(context)
bot_id = bot_id if bot_id is not None else context.bot_uuid
if not bot_id:
raise ValueError('Session activity requires a bot_id')
update_values = {
'last_activity': datetime.datetime.now(datetime.timezone.utc).replace(tzinfo=None),
'message_count': persistence_monitoring.MonitoringSession.message_count + 1,
@@ -667,6 +726,7 @@ class MonitoringService:
.where(
persistence_monitoring.MonitoringSession.workspace_uuid == workspace_uuid,
persistence_monitoring.MonitoringSession.session_id == session_id,
persistence_monitoring.MonitoringSession.bot_id == bot_id,
)
.values(update_values)
)
@@ -769,13 +829,13 @@ class MonitoringService:
message_conditions.append(persistence_monitoring.MonitoringMessage.timestamp >= start_time)
llm_conditions.append(persistence_monitoring.MonitoringLLMCall.timestamp >= start_time)
embedding_conditions.append(persistence_monitoring.MonitoringEmbeddingCall.timestamp >= start_time)
session_conditions.append(persistence_monitoring.MonitoringSession.start_time >= start_time)
session_conditions.append(persistence_monitoring.MonitoringSession.last_activity >= start_time)
if end_time:
message_conditions.append(persistence_monitoring.MonitoringMessage.timestamp <= end_time)
llm_conditions.append(persistence_monitoring.MonitoringLLMCall.timestamp <= end_time)
embedding_conditions.append(persistence_monitoring.MonitoringEmbeddingCall.timestamp <= end_time)
session_conditions.append(persistence_monitoring.MonitoringSession.start_time <= end_time)
session_conditions.append(persistence_monitoring.MonitoringSession.last_activity <= end_time)
# Total messages
message_query = sqlalchemy.select(sqlalchemy.func.count(persistence_monitoring.MonitoringMessage.id))
@@ -1257,6 +1317,7 @@ class MonitoringService:
pipeline_ids: list[str] | None = None,
start_time: datetime.datetime | None = None,
end_time: datetime.datetime | None = None,
user_query: str | None = None,
is_active: bool | None = None,
limit: int = 100,
offset: int = 0,
@@ -1271,9 +1332,17 @@ class MonitoringService:
if pipeline_ids:
conditions.append(persistence_monitoring.MonitoringSession.pipeline_id.in_(pipeline_ids))
if start_time:
conditions.append(persistence_monitoring.MonitoringSession.start_time >= start_time)
conditions.append(persistence_monitoring.MonitoringSession.last_activity >= start_time)
if end_time:
conditions.append(persistence_monitoring.MonitoringSession.start_time <= end_time)
conditions.append(persistence_monitoring.MonitoringSession.last_activity <= end_time)
if user_query and user_query.strip():
user_pattern = f'%{user_query.strip()}%'
conditions.append(
sqlalchemy.or_(
persistence_monitoring.MonitoringSession.user_id.ilike(user_pattern),
persistence_monitoring.MonitoringSession.user_name.ilike(user_pattern),
)
)
if is_active is not None:
conditions.append(persistence_monitoring.MonitoringSession.is_active == is_active)
@@ -1365,6 +1434,9 @@ class MonitoringService:
self,
context: TenantContext,
session_id: str,
start_time: datetime.datetime | None = None,
end_time: datetime.datetime | None = None,
bot_id: str | None = None,
) -> dict:
"""Get bounded session details with full statistics computed in SQL."""
workspace_uuid = require_workspace_uuid(context)
@@ -1374,8 +1446,13 @@ class MonitoringService:
persistence_monitoring.MonitoringSession.workspace_uuid == workspace_uuid,
persistence_monitoring.MonitoringSession.session_id == session_id,
)
session_result = await self.ap.persistence_mgr.execute_async(session_query)
session_row = session_result.first()
if bot_id is not None:
session_query = session_query.where(persistence_monitoring.MonitoringSession.bot_id == bot_id)
session_result = await self.ap.persistence_mgr.execute_async(session_query.limit(2))
session_rows = session_result.all()
if len(session_rows) > 1:
return {'session_id': session_id, 'found': False, 'ambiguous': True}
session_row = session_rows[0] if session_rows else None
if not session_row:
return {
@@ -1384,6 +1461,7 @@ class MonitoringService:
}
session = session_row[0] if isinstance(session_row, tuple) else session_row
bot_id = session.bot_id
message_stats_result = await self.ap.persistence_mgr.execute_async(
sqlalchemy.select(
@@ -1411,6 +1489,7 @@ class MonitoringService:
).where(
persistence_monitoring.MonitoringMessage.workspace_uuid == workspace_uuid,
persistence_monitoring.MonitoringMessage.session_id == session_id,
persistence_monitoring.MonitoringMessage.bot_id == bot_id,
)
)
message_stats = message_stats_result.one()
@@ -1449,6 +1528,7 @@ class MonitoringService:
).where(
persistence_monitoring.MonitoringLLMCall.workspace_uuid == workspace_uuid,
persistence_monitoring.MonitoringLLMCall.session_id == session_id,
persistence_monitoring.MonitoringLLMCall.bot_id == bot_id,
)
)
llm_stats = llm_stats_result.one()
@@ -1475,15 +1555,22 @@ class MonitoringService:
).where(
persistence_monitoring.MonitoringToolCall.workspace_uuid == workspace_uuid,
persistence_monitoring.MonitoringToolCall.session_id == session_id,
persistence_monitoring.MonitoringToolCall.bot_id == bot_id,
)
)
tool_stats = tool_stats_result.one()
tool_conditions = [
persistence_monitoring.MonitoringToolCall.workspace_uuid == workspace_uuid,
persistence_monitoring.MonitoringToolCall.session_id == session_id,
persistence_monitoring.MonitoringToolCall.bot_id == bot_id,
]
if start_time is not None:
tool_conditions.append(persistence_monitoring.MonitoringToolCall.timestamp >= start_time)
if end_time is not None:
tool_conditions.append(persistence_monitoring.MonitoringToolCall.timestamp <= end_time)
tool_query = (
sqlalchemy.select(persistence_monitoring.MonitoringToolCall)
.where(
persistence_monitoring.MonitoringToolCall.workspace_uuid == workspace_uuid,
persistence_monitoring.MonitoringToolCall.session_id == session_id,
)
.where(*tool_conditions)
.order_by(persistence_monitoring.MonitoringToolCall.timestamp.asc())
.limit(detail_limit + 1)
)
@@ -1504,6 +1591,7 @@ class MonitoringService:
.where(
persistence_monitoring.MonitoringError.workspace_uuid == workspace_uuid,
persistence_monitoring.MonitoringError.session_id == session_id,
persistence_monitoring.MonitoringError.bot_id == bot_id,
)
.order_by(persistence_monitoring.MonitoringError.timestamp.desc())
.limit(detail_limit + 1)
@@ -1988,9 +2076,9 @@ class MonitoringService:
if pipeline_ids:
conditions.append(persistence_monitoring.MonitoringSession.pipeline_id.in_(pipeline_ids))
if start_time:
conditions.append(persistence_monitoring.MonitoringSession.start_time >= start_time)
conditions.append(persistence_monitoring.MonitoringSession.last_activity >= start_time)
if end_time:
conditions.append(persistence_monitoring.MonitoringSession.start_time <= end_time)
conditions.append(persistence_monitoring.MonitoringSession.last_activity <= end_time)
query = sqlalchemy.select(persistence_monitoring.MonitoringSession).order_by(
persistence_monitoring.MonitoringSession.last_activity.desc()
@@ -2024,6 +2112,7 @@ class MonitoringService:
# ========== Feedback Methods ==========
@_workspace_transaction
async def record_feedback(
self,
context: ExecutionContext,
@@ -2038,7 +2127,7 @@ class MonitoringService:
session_id: str | None = None,
message_id: str | None = None,
stream_id: str | None = None,
user_id: str | None = None,
user_id: str | int | None = None,
platform: str | None = None,
) -> str | None:
"""Record user feedback (like/dislike) from AI Bot conversation.
@@ -2094,7 +2183,7 @@ class MonitoringService:
'session_id': session_id,
'message_id': message_id,
'stream_id': stream_id,
'user_id': user_id,
'user_id': _normalize_user_id(user_id),
'platform': platform,
}
dialect_name = self.ap.persistence_mgr.get_db_engine().dialect.name
@@ -0,0 +1,83 @@
"""Bounded traffic aggregation, independent of record-list pagination."""
from __future__ import annotations
import datetime
import typing
import sqlalchemy
from ....entity.persistence.monitoring import MonitoringLLMCall, MonitoringMessage
from .tenant import TenantContext, require_workspace_uuid
if typing.TYPE_CHECKING:
from ....core.app import Application
MAX_TRAFFIC_POINTS = 1000
async def get_traffic_series(
ap: Application,
context: TenantContext,
*,
bot_ids: list[str] | None = None,
pipeline_ids: list[str] | None = None,
start_time: datetime.datetime | None = None,
end_time: datetime.datetime | None = None,
) -> dict:
"""Count all matching records in UTC buckets, returning at most 1000 points."""
workspace_uuid = require_workspace_uuid(context)
bucket = 'hour' if start_time and end_time and end_time - start_time <= datetime.timedelta(days=7) else 'day'
step = datetime.timedelta(hours=1) if bucket == 'hour' else datetime.timedelta(days=1)
postgres = ap.persistence_mgr.get_db_engine().dialect.name == 'postgresql'
points: dict[datetime.datetime, dict[str, int]] = {}
truncated = False
for model, field in ((MonitoringMessage, 'messages'), (MonitoringLLMCall, 'llm_calls')):
timestamp = model.timestamp
if postgres:
time_bucket = sqlalchemy.func.date_trunc(bucket, timestamp)
else:
pattern = '%Y-%m-%dT%H:00:00' if bucket == 'hour' else '%Y-%m-%dT00:00:00'
time_bucket = sqlalchemy.func.strftime(pattern, timestamp)
conditions = [model.workspace_uuid == workspace_uuid]
if bot_ids:
conditions.append(model.bot_id.in_(bot_ids))
if pipeline_ids:
conditions.append(model.pipeline_id.in_(pipeline_ids))
if start_time is not None:
conditions.append(timestamp >= start_time)
if end_time is not None:
conditions.append(timestamp <= end_time)
statement = (
sqlalchemy.select(time_bucket.label('bucket'), sqlalchemy.func.count(model.id).label('count'))
.where(*conditions)
.group_by(time_bucket)
.order_by(time_bucket)
.limit(MAX_TRAFFIC_POINTS + 1)
)
result = await ap.persistence_mgr.execute_async(statement)
rows = result.all()
truncated = truncated or len(rows) > MAX_TRAFFIC_POINTS
for timestamp_value, count in rows[:MAX_TRAFFIC_POINTS]:
key = (
datetime.datetime.fromisoformat(timestamp_value)
if isinstance(timestamp_value, str)
else timestamp_value
)
points.setdefault(key, {'messages': 0, 'llm_calls': 0})[field] = int(count)
def floor(value: datetime.datetime) -> datetime.datetime:
return value.replace(minute=0, second=0, microsecond=0, **({'hour': 0} if bucket == 'day' else {}))
first = floor(start_time) if start_time is not None else min(points, default=None)
last = floor(end_time) if end_time is not None else max(points, default=None)
series = []
if first is not None and last is not None:
cursor = first
while cursor <= last and len(series) < MAX_TRAFFIC_POINTS:
series.append(
{'timestamp': cursor.isoformat() + 'Z', **points.get(cursor, {'messages': 0, 'llm_calls': 0})}
)
cursor += step
truncated = truncated or cursor <= last
return {'bucket': bucket, 'points': series, 'truncated': truncated}
+19 -7
View File
@@ -13,7 +13,7 @@ from ....pipeline.extension_preferences import (
validate_extension_preferences,
)
from ....workspace.errors import WorkspaceNotFoundError
from .secrets import contains_secret_placeholder, redact_secrets, restore_secret_placeholders
from .secrets import redact_secrets, restore_secret_placeholders
from .tenant import TenantContext, require_workspace_uuid, scope_statement
@@ -213,12 +213,24 @@ class PipelineService:
pipeline_data.pop(protected_field, None)
if 'config' in pipeline_data:
current_config = None
if contains_secret_placeholder(pipeline_data['config']):
current_pipeline = await self.get_pipeline(context, pipeline_uuid, include_secret=True)
if current_pipeline is None:
raise WorkspaceNotFoundError('Pipeline not found')
current_config = current_pipeline.get('config', {})
current_pipeline = await self.get_pipeline(context, pipeline_uuid, include_secret=True)
if current_pipeline is None:
raise WorkspaceNotFoundError('Pipeline not found')
current_config = current_pipeline.get('config', {})
old_ai = current_config.get('ai', {}) if isinstance(current_config, dict) else {}
old_runner = old_ai.get('runner') if isinstance(old_ai, dict) else None
legacy_binding = (
isinstance(old_runner, str)
and bool(old_runner)
or isinstance(old_runner, dict)
and bool(old_runner.get('runner'))
and not old_runner.get('id')
)
new_config = pipeline_data['config']
new_ai = new_config.get('ai', {}) if isinstance(new_config, dict) else {}
new_runner = new_ai.get('runner') if isinstance(new_ai, dict) else None
if legacy_binding and isinstance(new_runner, dict) and new_runner.get('id'):
raise ValueError('manual_migration_required')
pipeline_data['config'] = restore_secret_placeholders(
pipeline_data['config'],
current_config if current_config is not None else {},
@@ -0,0 +1,936 @@
"""Explicit, tenant-scoped migration. Preview never contacts plugin runtimes.
The task handle is process-local. Original snapshots and pending activation are
persistent; a refreshed preview is required after observation loss or restart.
"""
from __future__ import annotations
import asyncio
import copy
import hashlib
import hmac
import json
import math
import time
import secrets
import sys
import uuid
from langbot_plugin.runtime.plugin.mgr import PluginInstallSource
import httpx
import sqlalchemy as sa
from ..authz import Permission, permissions_for_role, require_permission
from ..context import ExecutionContext, PrincipalType, RequestContext
from ....agent.runner.config_resolver import RunnerConfigResolver
from ....agent.runner import config_schema
from ....agent.runner.resource_builder import AgentResourceBuilder
from ....agent.runner.resource_policy import ResourcePolicyProjector
from ....entity.persistence.model import LLMModel, RerankModel, EmbeddingModel
from ....entity.persistence.rag import KnowledgeBase
from ....entity.persistence.mcp import MCPServer
from ....core.taskmgr import TaskContext
from ....entity.persistence.pipeline import LegacyPipeline
from ....entity.persistence.agent_interaction import AgentInteraction
from ....persistence.pipeline_admission import lock_pipeline_admission
from ....entity.persistence.pipeline_migration import PipelineMigrationSnapshot
from ....entity.persistence.plugin import PluginSetting
from ....entity.persistence.user import User
from ....entity.persistence.workspace import Workspace, WorkspaceMembership, WorkspaceExecutionState
from ....plugin.errors import (
PluginRuntimeNotConnectedError,
MarketplacePluginVersionNotFoundError,
PluginInstallationFailedError,
)
from ....pipeline.legacy_config_migration import PLANNER_VERSION, plan_legacy_pipeline
class MigrationInstallContext(TaskContext):
"""Publish only structured installer progress, never upstream logs or URLs."""
ACTIONS = {
'downloading plugin package': 'downloading',
'validating plugin package': 'validating',
'preparing plugin installation': 'preparing',
'installing plugin dependencies': 'installing_deps',
'waiting for plugin initialization': 'activating',
'refreshing plugin components': 'refreshing',
'plugin installed': 'done',
}
METRICS = ('progress_percent', 'download_current', 'download_total', 'download_speed', 'deps_total')
def __init__(self, target):
super().__init__()
self.progress = {
**target,
'status': 'installing',
'stage': 'checking',
'code': None,
'started_at': time.time(),
'finished_at': None,
'steps': [],
}
self._step('checking')
def _step(self, stage):
steps = self.progress['steps']
if steps and steps[-1]['stage'] == stage:
return
now = time.time()
if steps:
steps[-1]['finished_at'] = now
steps.append({'stage': stage, 'started_at': now, 'finished_at': None})
self.progress['stage'] = stage
def set_current_action(self, action):
super().set_current_action(action)
stage = self.ACTIONS.get(action)
if stage:
self._step(stage)
self.publish()
def publish(self):
for key in self.METRICS:
value = self.metadata.get(key)
if type(value) in (int, float) and math.isfinite(value) and value >= 0:
self.progress[key] = min(value, 100) if key == 'progress_percent' else value
self.progress['updated_at'] = time.time()
def finish(self, code=None):
self.publish()
self.progress.update(status='failed' if code else 'completed', code=code, finished_at=time.time())
self.progress['steps'][-1]['finished_at'] = self.progress['finished_at']
if not code:
self.progress['progress_percent'] = 100
async def observe(self):
while True:
self.publish()
await asyncio.sleep(0.2)
class MigrationError(ValueError):
"""Only constant, credential-free codes cross the API/task boundary."""
def __init__(self, code: str, status_code: int = 409):
super().__init__(code)
self.code = code
self.status_code = status_code
def _failure_code(exc: Exception) -> str:
if isinstance(exc, MigrationError):
return exc.code
if isinstance(exc, PluginRuntimeNotConnectedError):
return 'plugin_runtime_unavailable'
return 'migration_failed'
def validate_execute_request(body) -> list[dict]:
if not isinstance(body, dict) or set(body) != {'confirmed', 'items'} or body['confirmed'] is not True:
raise MigrationError('confirmation_required', 400)
items = body['items']
if not isinstance(items, list) or not 1 <= len(items) <= 50:
raise MigrationError('invalid_selection', 400)
seen = set()
for item in items:
if not isinstance(item, dict) or set(item) != {'pipeline_uuid', 'preview_token'}:
raise MigrationError('invalid_selection', 400)
for key, maximum in [('pipeline_uuid', 255), ('preview_token', 256)]:
value = item[key]
if not isinstance(value, str) or not value.strip() or len(value) > maximum or value != value.strip():
raise MigrationError('invalid_selection', 400)
if item['pipeline_uuid'] in seen:
raise MigrationError('invalid_selection', 400)
seen.add(item['pipeline_uuid'])
return copy.deepcopy(items)
def _json(value):
return json.dumps(value, sort_keys=True, separators=(',', ':'), ensure_ascii=False, default=str)
def _fingerprint(row):
# SQL timestamps are neither monotonic revisions nor portable CAS values.
return hashlib.sha256(
_json(
{k: v for k, v in row.items() if not k.startswith('_') and k not in ('created_at', 'updated_at')}
).encode()
).hexdigest()
class PipelineMigrationService:
def __init__(self, ap):
self.ap = ap
self.pm = ap.persistence_mgr
# Loss of this process key only invalidates previews, never snapshots.
self._token_key = secrets.token_bytes(32)
self._all_tasks: set[str] = set()
async def _binding(self, ctx, session=None):
binding = await self.ap.workspace_service.get_execution_binding(
ctx.workspace_uuid, expected_generation=ctx.placement_generation, session=session
)
if binding.instance_uuid != ctx.instance_uuid:
raise MigrationError('placement_changed')
async def _authorize(self, ctx, session=None):
require_permission(ctx, Permission.RESOURCE_MANAGE)
if ctx.principal.principal_type != PrincipalType.ACCOUNT or not ctx.account_uuid:
raise MigrationError('authorization_changed', 403)
status = (await self.pm.execute_async(sa.select(User.status).where(User.uuid == ctx.account_uuid))).scalar()
if status != 'active':
raise MigrationError('authorization_changed', 403)
access = await self.ap.workspace_collaboration_service.resolve_account_workspace(
ctx.account_uuid, ctx.workspace_uuid, session=session
)
if (
access.workspace.uuid != ctx.workspace_uuid
or access.membership.uuid != ctx.workspace.membership_uuid
or access.membership.projection_revision != ctx.workspace.membership_revision
or Permission.RESOURCE_MANAGE.value not in permissions_for_role(access.membership.role)
or access.execution.instance_uuid != ctx.instance_uuid
or access.execution.placement_generation != ctx.placement_generation
):
raise MigrationError('authorization_changed', 403)
await self._binding(ctx, session)
async def _rows(self, ctx, pipeline_uuid=None):
# Read DB-native text for JSON CAS: never compare PostgreSQL JSON using =,
# and never assume Python's JSON whitespace/key order matches stored text.
table = LegacyPipeline.__table__
stmt = sa.select(
table,
*[sa.cast(table.c[k], sa.Text).label('_raw_' + k) for k in ('config', 'stages', 'extensions_preferences')],
)
stmt = stmt.where(table.c.workspace_uuid == ctx.workspace_uuid)
if pipeline_uuid is not None:
stmt = stmt.where(table.c.uuid == pipeline_uuid)
return [dict(r._mapping) for r in (await self.pm.execute_async(stmt.order_by(table.c.uuid))).all()]
async def _plugin(self, ctx, plan):
target = plan.get('target_plugin')
if not target:
return None
t = PluginSetting.__table__
row = (
await self.pm.execute_async(
sa.select(
t.c.enabled, t.c.installation_uuid, t.c.artifact_digest, t.c.runtime_revision, t.c.install_info
).where(
t.c.workspace_uuid == ctx.workspace_uuid,
t.c.plugin_author == target['author'],
t.c.plugin_name == target['name'],
)
)
).first()
return dict(row._mapping) if row else None
def _native_interactions(self, ctx, pipeline_uuid):
"""Inspect the old in-process cache without importing/pruning it.
Native forms have no durable handoff to the plugin runner. Operators
must finish them in the original runtime before migrating; never infer
completion from the cache TTL or submit/cancel a form here.
"""
module = sys.modules.get('langbot.pkg.provider.runners.difysvapi')
if module is None:
return []
cache = getattr(module, '_PENDING_FORMS', None)
if not isinstance(cache, dict):
return ['unavailable']
observed = []
for key, forms in cache.items():
if not isinstance(key, tuple) or len(key) != 8:
if forms:
observed.append('unscoped')
continue
instance, workspace, _, _, pipeline, *_ = key
# Inspect ownership before touching any form contents. Empty scope
# cannot prove this is another tenant; block without disclosing it.
if instance and instance != ctx.instance_uuid or workspace and workspace != ctx.workspace_uuid:
continue
if pipeline and pipeline != pipeline_uuid:
continue
if forms:
observed.append(hashlib.sha256(_json(key).encode()).hexdigest())
return sorted(observed)
async def _interaction_state(self, ctx, pipeline_uuid):
"""Read only correlation/status, never request/submission/token data.
The real table has workspace_id (not workspace_uuid), and no instance
column. Execution placement is verified by the caller; processor_id is
the globally unique pipeline UUID. NULL ownership on that exact UUID
is uncertainty, not permission to query other Workspaces.
"""
if self.pm.get_db_engine().dialect.name not in {'sqlite', 'postgresql'}:
return {'boundary': 'unavailable'}, True
t = AgentInteraction.__table__
try:
records = (
await self.pm.execute_async(
sa.select(t.c.id, t.c.status, t.c.updated_at, t.c.workspace_id)
.where(
t.c.processor_type == 'pipeline',
t.c.processor_id == pipeline_uuid,
sa.or_(
t.c.workspace_id == ctx.workspace_uuid, t.c.workspace_id.is_(None), t.c.workspace_id == ''
),
)
.order_by(t.c.id)
)
).all()
durable = [tuple(r) for r in records]
blocked = any(r.status not in {'submitted', 'cancelled', 'expired', 'delivery_failed'} for r in records)
except Exception:
# Missing schema, permissions, or a failed store must not be treated
# as an empty store. No original diagnostic crosses the API.
durable, blocked = ['unavailable'], True
native = self._native_interactions(ctx, pipeline_uuid)
return {'durable': durable, 'native': native}, blocked or bool(native)
async def _plan(self, ctx, row):
plan = plan_legacy_pipeline(row['config'], row['extensions_preferences'])
pending = None
if plan['state'] == 'already_current':
t = PipelineMigrationSnapshot.__table__
snapshots = (
await self.pm.execute_async(
sa.select(t).where(
t.c.workspace_uuid == ctx.workspace_uuid,
t.c.pipeline_uuid == row['uuid'],
t.c.state == 'activation_pending',
t.c.target_fingerprint == _fingerprint(row),
t.c.planner_version == str(PLANNER_VERSION),
)
)
).all()
if snapshots:
pending = dict(snapshots[0]._mapping)
source = pending['source_snapshot']
plan = plan_legacy_pipeline(source['config'], source['extensions_preferences'])
candidate = {**row, 'config': plan.get('config')}
if plan['state'] != 'ready' or _fingerprint(candidate) != _fingerprint(row):
raise MigrationError('planner_changed')
facts = await self._plugin(ctx, plan)
state = 'activation_pending' if pending else plan['state']
blockers = list(plan['blockers'])
if state in ('ready', 'activation_pending'):
if facts is None:
state = 'needs_plugin'
blockers.append({'code': 'plugin_missing'})
elif facts['enabled'] is not True:
state = 'blocked'
blockers.append({'code': 'plugin_disabled'})
if plan['state'] == 'ready':
observation, interaction_blocked = await self._interaction_state(ctx, row['uuid'])
plan['_interaction_state'] = observation
if interaction_blocked:
state = 'blocked'
blockers.append({'code': 'runtime.pending_interaction'})
return plan, facts, pending, state, blockers
def _token(self, ctx, row, plan, facts, pending):
claim = [
ctx.instance_uuid,
ctx.workspace_uuid,
ctx.placement_generation,
ctx.account_uuid,
ctx.workspace.membership_uuid,
ctx.workspace.membership_revision,
str(PLANNER_VERSION),
_fingerprint(row),
plan,
facts,
pending['uuid'] if pending else None,
]
# An authenticated opaque digest discloses neither credentials nor configs.
return hmac.new(self._token_key, _json(claim).encode(), hashlib.sha256).hexdigest()
async def preview(self, ctx: RequestContext):
require_permission(ctx, Permission.RESOURCE_VIEW)
async with self.pm.tenant_scope(ctx.workspace_uuid):
await self._binding(ctx)
items = []
for row in await self._rows(ctx):
plan, facts, pending, state, blockers = await self._plan(ctx, row)
item = {
k: copy.deepcopy(plan[k])
for k in ('legacy_runner', 'target_runner_id', 'target_plugin', 'changed_paths', 'warnings')
}
item.update(
pipeline_uuid=row['uuid'],
name=row['name'],
state=state,
blockers=blockers,
preview_token=self._token(ctx, row, plan, facts, pending)
if state in ('ready', 'activation_pending')
else None,
)
items.append(item)
return {
'planner_version': str(PLANNER_VERSION),
'workspace_uuid': ctx.workspace_uuid,
'items': items,
'total': len(items),
}
async def _selected(self, ctx, selection, *, data_only=False):
rows = await self._rows(ctx, selection['pipeline_uuid'])
if not rows:
raise MigrationError('pipeline_not_found', 404)
row = rows[0]
plan, facts, pending, state, blockers = await self._plan(ctx, row)
token = self._token(ctx, row, plan, facts, pending)
if not hmac.compare_digest(token, selection['preview_token']):
raise MigrationError('preview_stale')
if state not in ('ready', 'activation_pending') and not (
data_only
and plan['state'] == 'ready'
and all(b['code'] in ('plugin_missing', 'plugin_disabled') for b in blockers)
):
raise MigrationError('migration_blocked')
return row, plan, facts, pending
async def execute(self, ctx: RequestContext, body):
require_permission(ctx, Permission.RESOURCE_MANAGE)
if isinstance(body, dict) and body.get('all') is True:
if (
set(body) != {'confirmed', 'all', 'install_plugins'}
or body['confirmed'] is not True
or not isinstance(body['install_plugins'], bool)
):
raise MigrationError('confirmation_required', 400)
return await self._execute_all(ctx, install_plugins=body['install_plugins'])
items = validate_execute_request(body)
async with self.pm.tenant_scope(ctx.workspace_uuid):
await self._authorize(ctx)
# Preflight the entire selection before creating tasks or contacting runtimes.
for selection in items:
await self._selected(ctx, selection)
task_context = TaskContext.new()
task_context.metadata = {
'kind': 'pipeline_migration',
'results': [{'pipeline_uuid': i['pipeline_uuid'], 'state': 'pending', 'code': None} for i in items],
}
task = self.ap.task_mgr.create_user_task(
self._run(ctx, ExecutionContext.from_request(ctx), items, task_context),
kind='pipeline_migration',
name='pipeline_migration',
context=task_context,
instance_uuid=ctx.instance_uuid,
workspace_uuid=ctx.workspace_uuid,
placement_generation=ctx.placement_generation,
)
return {'task_id': task.id}
async def _execute_all(self, ctx, *, install_plugins):
# Capture all sources on admission. Plugin installation must not silently
# include later edits or pipelines created while the task is running.
if ctx.workspace_uuid in self._all_tasks:
raise MigrationError('migration_running')
self._all_tasks.add(ctx.workspace_uuid)
try:
async with self.pm.tenant_scope(ctx.workspace_uuid):
await self._authorize(ctx)
sources = []
for row in await self._rows(ctx):
_, _, _, state, _ = await self._plan(ctx, row)
if state not in ('already_current', 'not_legacy'):
sources.append(row)
if not sources:
raise MigrationError('nothing_to_migrate')
task_context = TaskContext.new()
task_context.metadata = {
'kind': 'pipeline_migration',
'phase': 'installing' if install_plugins else 'migrating',
'installations': [],
'results': [{'pipeline_uuid': row['uuid'], 'state': 'pending', 'code': None} for row in sources],
}
task = self.ap.task_mgr.create_user_task(
self._run_all(
ctx, ExecutionContext.from_request(ctx), sources, task_context, install_plugins=install_plugins
),
kind='pipeline_migration',
name='pipeline_migration',
context=task_context,
instance_uuid=ctx.instance_uuid,
workspace_uuid=ctx.workspace_uuid,
placement_generation=ctx.placement_generation,
)
return {'task_id': task.id, 'pipeline_uuids': [row['uuid'] for row in sources]}
except BaseException:
self._all_tasks.discard(ctx.workspace_uuid)
raise
async def _run_all(self, ctx, execution, sources, task_context, *, install_plugins):
installed = {}
try:
async with self.pm.tenant_scope(execution.workspace_uuid):
for source, result in zip(sources, task_context.metadata['results']):
if result['state'] != 'pending':
continue
try:
await self._authorize(ctx)
current = await self._rows(ctx, source['uuid'])
if not current or _fingerprint(current[0]) != _fingerprint(source):
raise MigrationError('preview_stale')
plan, facts, pending, state, blockers = await self._plan(ctx, current[0])
hard_blockers = [b for b in blockers if b['code'] not in ('plugin_missing', 'plugin_disabled')]
if hard_blockers:
raise MigrationError(hard_blockers[0]['code'])
if state in ('already_current', 'not_legacy'):
result.update(state='already_current', code=None)
continue
target = plan.get('target_plugin')
if not target:
raise MigrationError('migration_blocked')
if not install_plugins:
selection = {
'pipeline_uuid': source['uuid'],
'preview_token': self._token(ctx, current[0], plan, facts, pending),
}
await self._run(ctx, execution, [selection], task_context, results=[result], data_only=True)
continue
key = (target['author'], target['name'], target['version'])
if key not in installed:
runners = await self.ap.runner_registry.list_runners(
execution, use_cache=False, usage='agent'
)
descriptor = next((r for r in runners if r.id == plan['target_runner_id']), None)
ready = (
facts
and facts['enabled']
and descriptor
and descriptor.plugin_version == target['version']
)
if not ready:
task_context.metadata['phase'] = 'installing'
install_context = MigrationInstallContext(target)
task_context.metadata['installations'].append(install_context.progress)
observer = asyncio.create_task(install_context.observe())
install_code = 'operation_cancelled'
try:
await self._authorize(ctx)
await self.ap.plugin_connector.require_workspace_context(execution)
await self.ap.plugin_connector.install_plugin(
PluginInstallSource.MARKETPLACE,
{
'plugin_author': target['author'],
'plugin_name': target['name'],
'plugin_version': target['version'],
},
task_context=install_context,
)
install_code = None
except PluginRuntimeNotConnectedError:
install_code = 'plugin_runtime_unavailable'
except MarketplacePluginVersionNotFoundError:
install_code = 'plugin_version_unavailable'
except httpx.TimeoutException:
install_code = 'plugin_download_timeout'
except httpx.HTTPStatusError as exc:
install_code = (
'plugin_version_unavailable'
if exc.response.status_code == 404
else 'plugin_marketplace_unavailable'
)
except httpx.RequestError:
install_code = 'plugin_download_failed'
except PluginInstallationFailedError as exc:
install_code = {
'dependency_prepare_failed': 'dependency_prepare_failed',
'worker_launch_failed': 'plugin_launch_failed',
}.get(exc.error_code, 'plugin_install_failed')
except Exception:
install_code = 'plugin_install_failed'
finally:
observer.cancel()
try:
await observer
except asyncio.CancelledError:
pass
install_context.finish(install_code)
installed[key] = install_code
else:
installed[key] = None
if installed[key]:
raise MigrationError(installed[key])
await self._authorize(ctx)
current = await self._rows(ctx, source['uuid'])
if not current or _fingerprint(current[0]) != _fingerprint(source):
raise MigrationError('preview_stale')
plan, facts, pending, state, blockers = await self._plan(ctx, current[0])
if state not in ('ready', 'activation_pending'):
raise MigrationError(blockers[0]['code'] if blockers else 'migration_blocked')
selection = {
'pipeline_uuid': source['uuid'],
'preview_token': self._token(ctx, current[0], plan, facts, pending),
}
task_context.metadata['phase'] = 'migrating'
await self._run(ctx, execution, [selection], task_context, results=[result])
except Exception as exc:
result.update(
state='blocked' if isinstance(exc, MigrationError) else 'failed',
code=_failure_code(exc),
)
if any(
r['code'] in ('operation_cancelled', 'commit_outcome_unknown')
for r in task_context.metadata['results']
):
break
finally:
for result in task_context.metadata['results']:
if result['state'] == 'pending':
result.update(state='failed', code='operation_cancelled')
task_context.metadata['phase'] = 'finished'
self._all_tasks.discard(ctx.workspace_uuid)
return task_context.metadata
async def _verify_runtime(self, execution, plan):
runners = await self.ap.runner_registry.list_runners(execution, use_cache=False, usage='agent')
descriptor = next((r for r in runners if r.id == plan['target_runner_id']), None)
if descriptor is None or 'agent' not in descriptor.usages:
raise MigrationError('runner_unavailable')
if descriptor.plugin_version != plan['target_plugin']['version']:
raise MigrationError('plugin_version_incompatible')
config = plan['config']
RunnerConfigResolver.validate_pipeline_config(config)
runner_config = config['ai']['runner_config'][plan['target_runner_id']]
schema = {item['name']: item for item in descriptor.config_schema}
# Host fields have their own scope/capability checks below; plugin
# schema defaults must never implicitly authorize a foreign resource.
host_fields = {
'enable-all-tools': 'boolean',
'tools': 'array',
'knowledge-bases': 'knowledge-base-multi-selector',
'mcp-resources': 'array',
'mcp-resource-agent-read-enabled': 'boolean',
}
for name, value in runner_config.items():
field = schema.get(name)
if field is None and name in host_fields:
field = {'type': host_fields[name]}
if field is None:
raise MigrationError('runner_schema_incompatible')
if value is None and field.get('nullable') is True:
continue
kind = config_schema.normalize_schema_item_type(field.get('type'))
valid = (
(
kind
in (
'string',
'text',
'password',
'secret',
'select',
'llm-model-selector',
'embedding-model-selector',
'rerank-model-selector',
)
and isinstance(value, str)
)
or (kind == 'boolean' and type(value) is bool)
or (kind in ('integer', 'int') and type(value) is int)
or (kind in ('float', 'number') and type(value) in (int, float))
or (
kind
in (
'array',
'multi-select',
'knowledge-base-selector',
'knowledge-base-multi-selector',
'prompt-editor',
)
and isinstance(value, list)
)
or (kind in ('object', 'json') and type(value) is dict)
or (kind == 'array[string]' and type(value) is list and all(type(v) is str for v in value))
or (
kind == 'model-fallback-selector'
and (
isinstance(value, str)
or isinstance(value, dict)
and set(value) <= {'primary', 'fallbacks', 'reasoning'}
and isinstance(value.get('primary'), str)
and isinstance(value.get('fallbacks', []), list)
and all(isinstance(v, str) for v in value.get('fallbacks', []))
and isinstance(value.get('reasoning', {}), dict)
)
)
)
if not valid:
raise MigrationError('runner_schema_incompatible')
if kind == 'select' and field.get('options') and field.get('allow_custom') is not True:
if value not in [o.get('value', o.get('name')) for o in field['options']]:
raise MigrationError('runner_schema_incompatible')
if any(
f.get('required') and name not in runner_config and f.get('default') is None for name, f in schema.items()
):
raise MigrationError('runner_schema_incompatible')
await self._verify_resources(execution, descriptor, runner_config)
return _json(
[
descriptor.id,
descriptor.plugin_version,
descriptor.usages,
descriptor.config_schema,
getattr(descriptor, 'capabilities', None),
getattr(descriptor, 'permissions', None),
]
)
async def _verify_resources(self, execution, descriptor, runner_config):
"""Fail closed on references the scoped Host cannot authorize.
This runs only on explicit execution, outside the mutation transaction.
Resource use is still reauthorized by the Host on every eventual run.
"""
async def require_row(model, identifier, *, column=None):
column = model.uuid if column is None else column
found = (
await self.pm.execute_async(
sa.select(model.uuid).where(
model.workspace_uuid == execution.workspace_uuid,
column == identifier,
)
)
).first()
if found is None:
raise MigrationError('runner_resource_unavailable')
permissions = getattr(descriptor, 'permissions', None)
for model_type, model_uuid in config_schema.iter_config_model_refs(descriptor, runner_config):
allowed = set(getattr(permissions, 'models', []))
if not (allowed & ({'rerank'} if model_type == 'rerank' else {'invoke', 'stream'})):
raise MigrationError('runner_resource_unavailable')
await require_row(RerankModel if model_type == 'rerank' else LLMModel, model_uuid)
for field in config_schema.iter_schema_items(descriptor, {'embedding-model-selector'}):
value = runner_config.get(field['name'])
if value and value not in config_schema.NONE_SENTINELS:
await require_row(EmbeddingModel, value)
kb_ids = runner_config.get('knowledge-bases', [])
if not isinstance(kb_ids, list) or any(not isinstance(v, str) or not v for v in kb_ids):
raise MigrationError('runner_resource_unavailable')
if kb_ids:
if not config_schema.uses_host_knowledge_bases(descriptor) or 'retrieve' not in getattr(
permissions, 'knowledge_bases', []
):
raise MigrationError('runner_resource_unavailable')
for kb_id in kb_ids:
await require_row(KnowledgeBase, kb_id)
tool_names = runner_config.get('tools', [])
if not isinstance(tool_names, list) or any(not isinstance(v, str) or not v for v in tool_names):
raise MigrationError('runner_resource_unavailable')
tool_grant = (
runner_config.get('enable-all-tools', False)
or tool_names
or runner_config.get('mcp-resource-agent-read-enabled', False)
)
if tool_grant:
if not config_schema.uses_host_tools(descriptor) or 'call' not in getattr(permissions, 'tools', []):
raise MigrationError('runner_resource_unavailable')
policy = ResourcePolicyProjector.from_runner_config(runner_config)
tools = await AgentResourceBuilder(self.ap)._build_tools_from_binding(
execution,
permissions,
policy,
descriptor,
runner_config,
)
if not set(tool_names) <= {tool['tool_name'] for tool in tools}:
raise MigrationError('runner_resource_unavailable')
for attachment in runner_config.get('mcp-resources', []):
if not isinstance(attachment, dict):
raise MigrationError('runner_resource_unavailable')
identifier = attachment.get('server_uuid') or attachment.get('server_id')
name = attachment.get('server_name')
if identifier:
await require_row(MCPServer, identifier)
elif name:
await require_row(MCPServer, name, column=MCPServer.name)
else:
raise MigrationError('runner_resource_unavailable')
async def _lock(self, ctx, pipeline_uuid, session):
# Every pending producer shares this row and revalidates its captured
# configuration/conversation authority after acquiring admission.
if self.pm.get_db_engine().dialect.name not in {'sqlite', 'postgresql'}:
raise MigrationError('runtime.pending_interaction')
await lock_pipeline_admission(session, ctx.workspace_uuid, pipeline_uuid)
# Hold revocable directory rows through commit (no network in this UoW).
await session.execute(sa.select(User.uuid).where(User.uuid == ctx.account_uuid).with_for_update())
for model, condition in [
(Workspace, Workspace.uuid == ctx.workspace_uuid),
(WorkspaceExecutionState, WorkspaceExecutionState.workspace_uuid == ctx.workspace_uuid),
(
WorkspaceMembership,
sa.and_(
WorkspaceMembership.workspace_uuid == ctx.workspace_uuid,
WorkspaceMembership.account_uuid == ctx.account_uuid,
),
),
(PluginSetting, PluginSetting.workspace_uuid == ctx.workspace_uuid),
]:
await session.execute(sa.select(model.__table__).where(condition).with_for_update())
async def _commit(self, ctx, selection, expected_facts, candidate, *, data_only=False):
async with self.pm.tenant_uow(ctx.workspace_uuid) as uow:
await self._lock(ctx, selection['pipeline_uuid'], uow.session)
await self._authorize(ctx, uow.session)
row, plan, facts, pending = await self._selected(ctx, selection, data_only=data_only)
if facts != expected_facts:
raise MigrationError('plugin_changed')
if pending:
return pending['uuid'], pending['target_fingerprint']
target = {**row, 'config': candidate['config']}
t = LegacyPipeline.__table__
cas = [t.c.workspace_uuid == ctx.workspace_uuid, t.c.uuid == row['uuid']]
for key in ('config', 'stages', 'extensions_preferences'):
cas.append(sa.cast(t.c[key], sa.Text) == row['_raw_' + key])
result = await self.pm.execute_async(sa.update(t).where(*cas).values(config=candidate['config']))
if result.rowcount != 1:
raise MigrationError('preview_stale')
snapshot_uuid = str(uuid.uuid4())
target_fingerprint = _fingerprint(target)
await self.pm.execute_async(
sa.insert(PipelineMigrationSnapshot).values(
uuid=snapshot_uuid,
workspace_uuid=ctx.workspace_uuid,
pipeline_uuid=row['uuid'],
source_fingerprint=_fingerprint(row),
target_fingerprint=target_fingerprint,
source_snapshot=json.loads(_json({k: v for k, v in row.items() if not k.startswith('_')})),
planner_version=str(PLANNER_VERSION),
state='activation_pending',
)
)
if self._native_interactions(ctx, row['uuid']):
raise MigrationError('runtime.pending_interaction')
return snapshot_uuid, target_fingerprint
async def _activate(self, ctx, selection, snapshot_uuid, target_fingerprint, candidate, plan, expected_facts):
# Re-read the committed target under a short lock before synchronous
# publication. A later editor must never be replaced by our old candidate.
async with self.pm.tenant_uow(ctx.workspace_uuid) as uow:
await self._lock(ctx, selection['pipeline_uuid'], uow.session)
await self._authorize(ctx, uow.session)
rows = await self._rows(ctx, selection['pipeline_uuid'])
if not rows or _fingerprint(rows[0]) != target_fingerprint:
raise MigrationError('activation_source_changed')
if await self._plugin(ctx, plan) != expected_facts:
raise MigrationError('plugin_changed')
_, interaction_blocked = await self._interaction_state(ctx, selection['pipeline_uuid'])
if interaction_blocked:
raise MigrationError('runtime.pending_interaction')
# Native cache inspection above is the last await before publication
# and session invalidation; no native producer can interleave here.
self.ap.pipeline_mgr.publish_pipeline(candidate)
for session in self.ap.sess_mgr.session_list:
conversation = session.using_conversation
if (
conversation is not None
and conversation.pipeline_uuid == selection['pipeline_uuid']
and getattr(session, 'workspace_uuid', None) == ctx.workspace_uuid
):
session.using_conversation = None
t = PipelineMigrationSnapshot.__table__
await self.pm.execute_async(
sa.update(t)
.where(t.c.workspace_uuid == ctx.workspace_uuid, t.c.uuid == snapshot_uuid)
.values(state='active')
)
async def _run(self, ctx, execution, items, task_context, results=None, *, data_only=False):
async with self.pm.tenant_scope(execution.workspace_uuid):
for selection, result in zip(items, results if results is not None else task_context.metadata['results']):
committed = False
commit_attempted = False
try:
await self._authorize(ctx)
row, plan, facts, pending = await self._selected(ctx, selection, data_only=data_only)
runtime_schema = None if data_only else await self._verify_runtime(execution, plan)
RunnerConfigResolver.validate_pipeline_config(plan['config'])
candidate_entity = {k: copy.deepcopy(v) for k, v in row.items() if not k.startswith('_')}
candidate_entity['config'] = copy.deepcopy(plan['config'])
runtime = await self.ap.pipeline_mgr.prepare_pipeline(execution, copy.deepcopy(candidate_entity))
if not data_only and await self._verify_runtime(execution, plan) != runtime_schema:
raise MigrationError('runner_schema_changed')
# Runtime awaits are over. Recheck authorization/source/plugin
# facts under database locks, then atomically journal and CAS.
commit_attempted = True
snapshot_uuid, target_fingerprint = await self._commit(
ctx, selection, facts, candidate_entity, **({'data_only': True} if data_only else {})
)
committed = True
await self._activate(ctx, selection, snapshot_uuid, target_fingerprint, runtime, plan, facts)
result.update(state='migrated', code='data_only' if data_only else None)
except (Exception, asyncio.CancelledError) as exc:
cancelled = isinstance(exc, asyncio.CancelledError)
reconciliation_cancel = None
code = 'operation_cancelled' if cancelled else _failure_code(exc)
if commit_attempted and not committed and not isinstance(exc, MigrationError):
# A commit can succeed while acknowledgement/connection
# cleanup fails, including cancellation. Make one read
# after the UoW has unwound, in this task's tenant scope;
# never shield/retry through another cancellation.
try:
t = PipelineMigrationSnapshot.__table__
conditions = [
t.c.workspace_uuid == ctx.workspace_uuid,
t.c.pipeline_uuid == selection['pipeline_uuid'],
t.c.source_fingerprint
== (pending['source_fingerprint'] if pending else _fingerprint(row)),
t.c.target_fingerprint == _fingerprint(candidate_entity),
t.c.planner_version == str(PLANNER_VERSION),
]
if pending:
# A retry starts from the target, not the legacy
# source. Only the original snapshot proves this
# durable migration; an unrelated journal cannot.
conditions.extend(
[
t.c.uuid == pending['uuid'],
t.c.target_fingerprint == pending['target_fingerprint'],
]
)
found = (await self.pm.execute_async(sa.select(t.c.uuid).where(*conditions))).first()
committed = found is not None
except asyncio.CancelledError as cancel_exc:
reconciliation_cancel = cancel_exc
cancelled = True
code = 'commit_outcome_unknown'
except Exception:
code = 'commit_outcome_unknown'
state = (
# Unknown is deliberately recovery-needed, not a claim
# of rollback or proof that a snapshot was committed.
'activation_pending'
if committed or code == 'commit_outcome_unknown'
else 'stale'
if code == 'preview_stale'
else 'blocked'
if isinstance(exc, MigrationError)
else 'failed'
)
result.update(state=state, code=code)
if cancelled:
for unstarted in task_context.metadata['results']:
if unstarted['state'] == 'pending':
unstarted.update(state='failed', code='operation_cancelled')
if reconciliation_cancel is not None:
# Preserve cancellation propagation during recovery,
# but record safe outcomes before leaving the task.
raise reconciliation_cancel
return task_context.metadata
# Never attach the original exception, traceback or plugin
# diagnostic to TaskContext: upstream errors can contain keys.
return task_context.metadata
+129 -50
View File
@@ -1,5 +1,6 @@
from __future__ import annotations
import asyncio
import uuid
import traceback
@@ -7,8 +8,10 @@ import sqlalchemy
from ....cloud.model_catalog import LANGBOT_MODELS_PROVIDER_REQUESTER
from ....core import app
from ....core.task_boundary import create_detached_task
from ....entity.persistence import model as persistence_model
from ....workspace.errors import WorkspaceNotFoundError
from ....provider.modelmgr.codex_auth import CodexAuth, REQUESTER as CODEX_REQUESTER, validate_config
from .secrets import contains_secret_placeholder, redact_secrets, restore_secret_placeholders
from .tenant import TenantContext, require_workspace_uuid, scope_statement
@@ -20,6 +23,8 @@ class ModelProviderService:
def __init__(self, ap: app.Application) -> None:
self.ap = ap
self.codex_auth = CodexAuth(ap)
self._deletion_tasks: set[asyncio.Task[None]] = set()
def _is_cloud_runtime(self) -> bool:
mode = getattr(self.ap.persistence_mgr, 'mode', None)
@@ -116,14 +121,30 @@ class ModelProviderService:
provider_data = provider_data.copy()
if self._system_requester_is_reserved(provider_data.get('requester')):
raise ValueError('space-chat-completions is reserved for the Cloud-managed LangBot Models provider')
validate_config(provider_data)
provider_data['uuid'] = str(uuid.uuid4())
provider_data['workspace_uuid'] = require_workspace_uuid(context)
provider_data['api_keys'] = self._normalize_api_keys(
restore_secret_placeholders(provider_data.get('api_keys'), sensitive=True)
)
await self.ap.persistence_mgr.execute_async(
sqlalchemy.insert(persistence_model.ModelProvider).values(**provider_data)
)
if provider_data.get('requester') == CODEX_REQUESTER:
async with self.ap.persistence_mgr.tenant_uow(provider_data['workspace_uuid']):
await self.ap.persistence_mgr.execute_async(
sqlalchemy.insert(persistence_model.ModelProvider).values(**provider_data)
)
await self.ap.persistence_mgr.execute_async(
sqlalchemy.insert(persistence_model.CodexCredential).values(
workspace_uuid=provider_data['workspace_uuid'],
provider_uuid=provider_data['uuid'],
payload={},
version=0,
lease_until=0,
)
)
else:
await self.ap.persistence_mgr.execute_async(
sqlalchemy.insert(persistence_model.ModelProvider).values(**provider_data)
)
# load to runtime
runtime_provider = await self.ap.model_mgr.load_provider(context, provider_data)
@@ -138,6 +159,17 @@ class ModelProviderService:
raise ValueError('space-chat-completions is reserved for the Cloud-managed LangBot Models provider')
provider_data.pop('uuid', None)
provider_data.pop('workspace_uuid', None)
if {'requester', 'base_url', 'api_keys'} & provider_data.keys():
current = await self.get_provider(context, provider_uuid, include_secret=True)
if current is None:
raise WorkspaceNotFoundError('Provider not found')
if CODEX_REQUESTER in (current.get('requester'), provider_data.get('requester')):
if provider_data.get('requester', current.get('requester')) != current.get('requester'):
raise ValueError('Create a separate provider to change the ChatGPT authentication type')
merged = {**current, **provider_data}
validate_config(merged)
provider_data['base_url'] = merged['base_url']
provider_data['api_keys'] = []
if 'api_keys' in provider_data:
submitted_keys = provider_data.get('api_keys')
if contains_secret_placeholder(submitted_keys, sensitive=True):
@@ -163,60 +195,107 @@ class ModelProviderService:
raise WorkspaceNotFoundError('Provider not found')
await self.ap.model_mgr.reload_provider(context, provider_uuid)
async def delete_provider(self, context: TenantContext, provider_uuid: str) -> None:
"""Delete a provider (only if no models reference it)"""
await self._assert_provider_mutable(context, provider_uuid)
async def delete_provider(self, context: TenantContext, provider_uuid: str, cascade: bool = False) -> None:
"""Delete a provider, optionally deleting all its Workspace-scoped models."""
workspace_uuid = require_workspace_uuid(context)
# Check if any models use this provider
llm_result = await self.ap.persistence_mgr.execute_async(
scope_statement(
sqlalchemy.select(persistence_model.LLMModel).where(
persistence_model.LLMModel.provider_uuid == provider_uuid
),
persistence_model.LLMModel,
workspace_uuid,
)
persistence = self.ap.persistence_mgr
model_types = (
(persistence_model.LLMModel, 'LLM', 'remove_llm_model'),
(persistence_model.EmbeddingModel, 'Embedding', 'remove_embedding_model'),
(persistence_model.RerankModel, 'Rerank', 'remove_rerank_model'),
)
if llm_result.first() is not None:
raise ValueError('Cannot delete provider: LLM models still reference it')
embedding_result = await self.ap.persistence_mgr.execute_async(
scope_statement(
sqlalchemy.select(persistence_model.EmbeddingModel).where(
persistence_model.EmbeddingModel.provider_uuid == provider_uuid
),
persistence_model.EmbeddingModel,
workspace_uuid,
deleted_models: list[tuple[str, list[str]]] = []
async with persistence.tenant_uow(workspace_uuid):
# Check ownership before touching children. Lock the provider on PostgreSQL
# so concurrent model inserts cannot race the reference check/deletion.
provider_result = await persistence.execute_async(
scope_statement(
sqlalchemy.select(persistence_model.ModelProvider.requester)
.where(persistence_model.ModelProvider.uuid == provider_uuid)
.with_for_update(),
persistence_model.ModelProvider,
workspace_uuid,
)
)
)
if embedding_result.first() is not None:
raise ValueError('Cannot delete provider: Embedding models still reference it')
provider = provider_result.first()
if provider is None:
raise WorkspaceNotFoundError('Provider not found')
if self._system_requester_is_reserved(provider.requester):
raise ValueError('LangBot Models is managed by Cloud and cannot be modified')
rerank_result = await self.ap.persistence_mgr.execute_async(
scope_statement(
sqlalchemy.select(persistence_model.RerankModel).where(
persistence_model.RerankModel.provider_uuid == provider_uuid
),
persistence_model.RerankModel,
workspace_uuid,
for model_type, label, remover in model_types:
result = await persistence.execute_async(
scope_statement(
sqlalchemy.select(model_type.uuid).where(model_type.provider_uuid == provider_uuid),
model_type,
workspace_uuid,
)
)
model_uuids = list(result.scalars())
if model_uuids and not cascade:
raise ValueError(f'Cannot delete provider: {label} models still reference it')
if model_uuids:
# Model services have no pipeline/KB deletion side effects: they
# delete the scoped row and evict its runtime cache. Defer eviction
# here rather than calling those services before our commit.
await persistence.execute_async(
scope_statement(
sqlalchemy.delete(model_type).where(model_type.provider_uuid == provider_uuid),
model_type,
workspace_uuid,
)
)
deleted_models.append((remover, model_uuids))
# Explicit cleanup also works on legacy SQLite connections without FK
# enforcement; never load or serialize the private credential payload.
await persistence.execute_async(
scope_statement(
sqlalchemy.delete(persistence_model.CodexCredential).where(
persistence_model.CodexCredential.provider_uuid == provider_uuid
),
persistence_model.CodexCredential,
workspace_uuid,
)
)
)
if rerank_result.first() is not None:
raise ValueError('Cannot delete provider: Rerank models still reference it')
result = await self.ap.persistence_mgr.execute_async(
scope_statement(
sqlalchemy.delete(persistence_model.ModelProvider).where(
persistence_model.ModelProvider.uuid == provider_uuid
),
persistence_model.ModelProvider,
workspace_uuid,
result = await persistence.execute_async(
scope_statement(
sqlalchemy.delete(persistence_model.ModelProvider).where(
persistence_model.ModelProvider.uuid == provider_uuid
),
persistence_model.ModelProvider,
workspace_uuid,
)
)
)
if getattr(result, 'rowcount', None) == 0:
raise WorkspaceNotFoundError('Provider not found')
if result.rowcount == 0:
raise WorkspaceNotFoundError('Provider not found')
await self.ap.model_mgr.remove_provider(context, provider_uuid)
async def remove_runtime() -> None:
async with persistence.tenant_scope(workspace_uuid):
for remover, model_uuids in deleted_models:
for model_uuid in model_uuids:
await getattr(self.ap.model_mgr, remover)(context, model_uuid)
# This also closes the requester's HTTP client; models go first.
await self.ap.model_mgr.remove_provider(context, provider_uuid)
if persistence.current_session() is None:
await remove_runtime()
else:
# A nested UoW has not committed yet. Reuse the rollback-cancelled gate
# and detached context boundary instead of evicting uncommitted data.
task = create_detached_task(
remove_runtime(),
after_commit_manager=persistence,
workspace_uuid=workspace_uuid,
)
self._deletion_tasks.add(task)
def completed(task: asyncio.Task[None]) -> None:
self._deletion_tasks.discard(task)
if not task.cancelled() and task.exception() is not None:
self.ap.logger.error('Failed to remove deleted provider runtime', exc_info=task.exception())
task.add_done_callback(completed)
async def get_provider_model_counts(self, context: TenantContext, provider_uuid: str) -> dict:
"""Get count of models using this provider"""
+4 -1
View File
@@ -119,7 +119,7 @@ class SpaceService:
space_config = self._get_space_config()
authorize_url = space_config['oauth_authorize_url']
params = {'redirect_uri': redirect_uri}
params = {'redirect_uri': redirect_uri, 'code_contract': 'redirect-v1'}
if state:
params['state'] = state
return f'{authorize_url}?{urlencode(params)}'
@@ -129,6 +129,8 @@ class SpaceService:
code: str,
workspace_uuids: list[str] | None = None,
workspace_created_ats: dict[str, int] | None = None,
*,
redirect_uri: str = '',
) -> typing.Dict:
"""Exchange OAuth authorization code for tokens"""
from langbot.pkg.utils import constants
@@ -141,6 +143,7 @@ class SpaceService:
f'{space_url}/api/v1/accounts/oauth/token',
json={
'code': code,
'redirect_uri': redirect_uri,
'instance_id': constants.instance_id,
# Sending an explicit empty list tells new Space servers not to
# synthesize a legacy instance-derived Workspace binding.
+336 -2
View File
@@ -4,6 +4,7 @@ import sqlalchemy
import argon2
import jwt
import datetime
import json
import typing
import asyncio
import dataclasses
@@ -12,10 +13,19 @@ import hashlib
import secrets
import time
import uuid
import webauthn
from webauthn.helpers import bytes_to_base64url, base64url_to_bytes
from webauthn.helpers.structs import (
AuthenticatorSelectionCriteria,
PublicKeyCredentialDescriptor,
ResidentKeyRequirement,
UserVerificationRequirement,
)
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
from ....entity.persistence import user
from ....entity.persistence import passkey
from ....entity.persistence.workspace import MembershipRole, MembershipStatus, WorkspaceMembership
from ....utils import constants
from ....entity.errors import account as account_errors
@@ -29,6 +39,9 @@ if typing.TYPE_CHECKING:
_SPACE_OAUTH_STATE_MAX_ENTRIES = 4096
_SPACE_OAUTH_STATE_HEAP_COMPACT_FLOOR = 64
_SPACE_OAUTH_STATE_HEAP_MAX_MULTIPLIER = 4
_PASSKEY_CHALLENGE_MAX_ENTRIES = 4096
_PASSKEY_CHALLENGE_HEAP_COMPACT_FLOOR = 64
_PASSKEY_CHALLENGE_HEAP_MAX_MULTIPLIER = 4
class AccountExistsLoginRequiredError(ValueError):
@@ -54,6 +67,17 @@ class SpaceOAuthStateConsumption:
launch_workspace_uuid: str | None = None
@dataclasses.dataclass(frozen=True, slots=True)
class PasskeyChallengeData:
challenge: bytes
purpose: typing.Literal['register', 'auth']
rp_id: str
origin: str
expires_at: float
account_uuid: str | None = None
user_email: str | None = None
class UserService:
ap: Application
_create_user_lock: asyncio.Lock
@@ -65,6 +89,9 @@ class UserService:
self._space_oauth_state_lock = asyncio.Lock()
self._space_oauth_states: dict[str, tuple[str, str | None, float, str | None]] = {}
self._space_oauth_state_expiry_heap: list[tuple[float, str]] = []
self._passkey_challenge_lock = asyncio.Lock()
self._passkey_challenges: dict[str, PasskeyChallengeData] = {}
self._passkey_challenge_expiry_heap: list[tuple[float, str]] = []
@staticmethod
def _space_oauth_state_digest(state: str) -> str:
@@ -774,7 +801,7 @@ class UserService:
f'email:{normalized_email}',
)
async def bind_space_account(self, user_email: str, code: str) -> user.User:
async def bind_space_account(self, user_email: str, code: str, *, redirect_uri: str = '') -> user.User:
"""Bind Space account to existing local account"""
local_account = await self.get_user_by_email(user_email)
if local_account is None:
@@ -794,12 +821,13 @@ class UserService:
code,
[binding.workspace_uuid],
{binding.workspace_uuid: created_ts},
redirect_uri=redirect_uri,
)
else:
# Compatibility for early/bootstrap call sites that have not wired
# WorkspaceService yet; old Space servers still derive the legacy
# Workspace identity from instance_id when the field is omitted.
token_data = await self.ap.space_service.exchange_oauth_code(code)
token_data = await self.ap.space_service.exchange_oauth_code(code, redirect_uri=redirect_uri)
access_token = token_data.get('access_token')
refresh_token = token_data.get('refresh_token')
expires_in = token_data.get('expires_in', 0)
@@ -849,3 +877,309 @@ class UserService:
await self._update_space_provider_for_account(local_account, api_key)
return await self.get_user_by_email(space_email)
def _prune_passkey_challenges(self, now: float) -> None:
while self._passkey_challenge_expiry_heap:
expires_at, token = self._passkey_challenge_expiry_heap[0]
entry = self._passkey_challenges.get(token)
if entry is None or entry.expires_at != expires_at:
heapq.heappop(self._passkey_challenge_expiry_heap)
continue
if expires_at > now:
break
heapq.heappop(self._passkey_challenge_expiry_heap)
self._passkey_challenges.pop(token, None)
max_heap_entries = max(
_PASSKEY_CHALLENGE_HEAP_COMPACT_FLOOR,
len(self._passkey_challenges) * _PASSKEY_CHALLENGE_HEAP_MAX_MULTIPLIER,
)
if len(self._passkey_challenge_expiry_heap) > max_heap_entries:
self._passkey_challenge_expiry_heap[:] = [
(entry.expires_at, token) for token, entry in self._passkey_challenges.items()
]
heapq.heapify(self._passkey_challenge_expiry_heap)
async def issue_passkey_challenge(
self,
purpose: typing.Literal['register', 'auth'],
rp_id: str,
origin: str,
*,
account_uuid: str | None = None,
user_email: str | None = None,
ttl_seconds: int = 300,
) -> tuple[str, bytes]:
now = time.monotonic()
challenge_bytes = secrets.token_bytes(32)
challenge_token = secrets.token_urlsafe(32)
expires_at = now + ttl_seconds
async with self._passkey_challenge_lock:
self._prune_passkey_challenges(now)
while len(self._passkey_challenges) >= _PASSKEY_CHALLENGE_MAX_ENTRIES:
if not self._passkey_challenge_expiry_heap:
break
_, oldest_token = heapq.heappop(self._passkey_challenge_expiry_heap)
self._passkey_challenges.pop(oldest_token, None)
self._passkey_challenges[challenge_token] = PasskeyChallengeData(
challenge=challenge_bytes,
purpose=purpose,
rp_id=rp_id,
origin=origin,
expires_at=expires_at,
account_uuid=account_uuid,
user_email=user_email,
)
heapq.heappush(self._passkey_challenge_expiry_heap, (expires_at, challenge_token))
return challenge_token, challenge_bytes
async def consume_passkey_challenge(
self,
challenge_token: str,
purpose: typing.Literal['register', 'auth'],
) -> PasskeyChallengeData:
now = time.monotonic()
async with self._passkey_challenge_lock:
self._prune_passkey_challenges(now)
data = self._passkey_challenges.pop(challenge_token, None)
if data is None or data.expires_at < now:
raise ValueError('Invalid or expired passkey challenge')
if data.purpose != purpose:
raise ValueError('Passkey challenge purpose mismatch')
return data
async def get_user_passkeys(self, account_uuid: str) -> list[passkey.PasskeyCredential]:
statement = (
sqlalchemy.select(passkey.PasskeyCredential)
.where(passkey.PasskeyCredential.account_uuid == account_uuid)
.order_by(passkey.PasskeyCredential.created_at.desc())
)
async with self._session_factory()() as session:
result = await session.scalars(statement)
return list(result.all())
async def get_passkey_by_credential_id(self, credential_id: str) -> passkey.PasskeyCredential | None:
statement = sqlalchemy.select(passkey.PasskeyCredential).where(
passkey.PasskeyCredential.credential_id == credential_id
)
async with self._session_factory()() as session:
return await session.scalar(statement)
async def get_passkey_by_uuid(self, passkey_uuid: str) -> passkey.PasskeyCredential | None:
statement = sqlalchemy.select(passkey.PasskeyCredential).where(passkey.PasskeyCredential.uuid == passkey_uuid)
async with self._session_factory()() as session:
return await session.scalar(statement)
async def generate_passkey_registration_options(
self,
account_uuid: str,
rp_id: str,
origin: str,
rp_name: str = 'LangBot',
) -> tuple[dict[str, typing.Any], str]:
account = await self.get_user_by_uuid(account_uuid)
if account is None:
raise ValueError('User not found')
self._require_active_account(account)
challenge_token, challenge_bytes = await self.issue_passkey_challenge(
purpose='register',
rp_id=rp_id,
origin=origin,
account_uuid=account_uuid,
user_email=account.user,
)
existing_passkeys = await self.get_user_passkeys(account_uuid)
exclude_credentials = [
PublicKeyCredentialDescriptor(id=base64url_to_bytes(pk.credential_id)) for pk in existing_passkeys
]
options = webauthn.generate_registration_options(
rp_id=rp_id,
rp_name=rp_name,
user_name=account.user,
user_id=account.uuid.encode('utf-8'),
user_display_name=account.user,
challenge=challenge_bytes,
exclude_credentials=exclude_credentials or None,
authenticator_selection=AuthenticatorSelectionCriteria(
resident_key=ResidentKeyRequirement.PREFERRED,
),
)
options_dict = json.loads(webauthn.options_to_json(options))
return options_dict, challenge_token
async def verify_and_save_passkey_registration(
self,
challenge_token: str,
credential_data: dict[str, typing.Any] | str,
name: str | None = None,
) -> passkey.PasskeyCredential:
challenge_data = await self.consume_passkey_challenge(challenge_token, 'register')
if not challenge_data.account_uuid:
raise ValueError('Registration challenge must be bound to an account')
verification = webauthn.verify_registration_response(
credential=credential_data,
expected_challenge=challenge_data.challenge,
expected_rp_id=challenge_data.rp_id,
expected_origin=challenge_data.origin,
require_user_verification=False,
)
cred_id_str = bytes_to_base64url(verification.credential_id)
pub_key_str = bytes_to_base64url(verification.credential_public_key)
transports = None
if isinstance(credential_data, dict):
resp = credential_data.get('response', {})
if isinstance(resp, dict) and 'transports' in resp:
t_list = resp.get('transports')
if isinstance(t_list, list):
transports = ','.join(str(x) for x in t_list)
credential_name = (name or '').strip()
if not credential_name:
credential_name = f'Passkey ({datetime.datetime.now().strftime("%Y-%m-%d %H:%M")})'
record = passkey.PasskeyCredential(
uuid=str(uuid.uuid4()),
account_uuid=challenge_data.account_uuid,
name=credential_name,
credential_id=cred_id_str,
public_key=pub_key_str,
sign_count=verification.sign_count,
aaguid=verification.aaguid,
transports=transports,
backed_up=verification.credential_backed_up,
)
async with self._session_factory()() as session:
async with session.begin():
session.add(record)
await session.flush()
await session.refresh(record)
return record
async def generate_passkey_authentication_options(
self,
rp_id: str,
origin: str,
email: str | None = None,
) -> tuple[dict[str, typing.Any], str]:
challenge_token, challenge_bytes = await self.issue_passkey_challenge(
purpose='auth',
rp_id=rp_id,
origin=origin,
user_email=email,
)
allow_credentials: list[PublicKeyCredentialDescriptor] | None = None
if email:
user_obj = await self.get_user_by_email(email)
if user_obj:
user_passkeys = await self.get_user_passkeys(user_obj.uuid)
if user_passkeys:
allow_credentials = [
PublicKeyCredentialDescriptor(id=base64url_to_bytes(pk.credential_id)) for pk in user_passkeys
]
options = webauthn.generate_authentication_options(
rp_id=rp_id,
challenge=challenge_bytes,
allow_credentials=allow_credentials or None,
user_verification=UserVerificationRequirement.PREFERRED,
)
options_dict = json.loads(webauthn.options_to_json(options))
return options_dict, challenge_token
async def verify_passkey_authentication(
self,
challenge_token: str,
credential_data: dict[str, typing.Any] | str,
) -> tuple[str, user.User]:
challenge_data = await self.consume_passkey_challenge(challenge_token, 'auth')
raw_id = credential_data.get('id') if isinstance(credential_data, dict) else None
if not raw_id:
raise ValueError('Missing credential id')
stored_credential = await self.get_passkey_by_credential_id(raw_id)
if stored_credential is None:
raise ValueError('Passkey credential not recognized')
user_obj = await self.get_user_by_uuid(stored_credential.account_uuid)
if user_obj is None:
raise ValueError('Associated user not found')
self._require_active_account(user_obj)
verification = webauthn.verify_authentication_response(
credential=credential_data,
expected_challenge=challenge_data.challenge,
expected_rp_id=challenge_data.rp_id,
expected_origin=challenge_data.origin,
credential_public_key=base64url_to_bytes(stored_credential.public_key),
credential_current_sign_count=stored_credential.sign_count,
require_user_verification=False,
)
async with self._session_factory()() as session:
async with session.begin():
record = await session.scalar(
sqlalchemy.select(passkey.PasskeyCredential).where(
passkey.PasskeyCredential.id == stored_credential.id
)
)
if record:
record.sign_count = verification.new_sign_count
record.last_used_at = datetime.datetime.now()
record.backed_up = verification.credential_backed_up
token = await self.generate_jwt_token(user_obj)
return token, user_obj
async def rename_user_passkey(
self,
account_uuid: str,
passkey_uuid: str,
new_name: str,
) -> passkey.PasskeyCredential | None:
async with self._session_factory()() as session:
async with session.begin():
record = await session.scalar(
sqlalchemy.select(passkey.PasskeyCredential).where(
passkey.PasskeyCredential.uuid == passkey_uuid,
passkey.PasskeyCredential.account_uuid == account_uuid,
)
)
if record is None:
return None
record.name = new_name
await session.flush()
await session.refresh(record)
return record
async def delete_user_passkey(
self,
account_uuid: str,
passkey_uuid: str,
) -> bool:
async with self._session_factory()() as session:
async with session.begin():
record = await session.scalar(
sqlalchemy.select(passkey.PasskeyCredential).where(
passkey.PasskeyCredential.uuid == passkey_uuid,
passkey.PasskeyCredential.account_uuid == account_uuid,
)
)
if record is None:
return False
await session.delete(record)
return True
+4 -2
View File
@@ -227,14 +227,16 @@ class LangBotMCPServer:
return _dump(await ap.agent_service.get_agent_metadata(context))
@mcp.tool(
description='List one Event processor instance run history; use before_id to page older runs. '
description='List one Agent or plugin processor run history; use before_id to page older runs. '
'created_at_ms, started_at_ms and finished_at_ms are Host lifecycle times in epoch milliseconds.'
)
async def list_processor_runs(processor_uuid: str, before_id: int | None = None) -> str:
context = _authorized(Permission.RESOURCE_VIEW)
return _dump(await ap.agent_service.get_processor_runs(context, processor_uuid, before_id=before_id))
@mcp.tool(description='Read logs and action results for an Event processor run; page using after_sequence.')
@mcp.tool(
description='Read logs and action results for an Agent or plugin processor run; page using after_sequence.'
)
async def get_processor_run_events(
processor_uuid: str,
run_id: str,
+233
View File
@@ -0,0 +1,233 @@
"""Runner-owned Box selection over Host-owned execution and file services."""
from __future__ import annotations
import asyncio
import copy
import base64
import hashlib
import uuid
from dataclasses import dataclass, field, replace
from langbot_plugin.api.entities.builtin.platform import message as pm
from langbot_plugin.api.entities.builtin.runner.box import BoxAcquireRequest
from langbot_plugin.box.errors import BoxValidationError
@dataclass
class RunBoxBinding:
run_id: str
session_id: str
spec: dict
io_scope: str
imported: dict = field(default_factory=dict)
exported: dict = field(default_factory=dict)
submitted: set = field(default_factory=set)
lock: asyncio.Lock = field(default_factory=asyncio.Lock)
def prepare_input_files(query, agent_input):
"""Retain provider inputs and issue references without downloading files.
Only platform-origin components may carry a Host-local path. A path in an
event envelope is metadata, never permission to read the Host filesystem.
"""
from langbot_plugin.api.entities.builtin.runner.input import InputAttachment
from ..agent.runner.query_entry_adapter import QueryEntryAdapter
originals = input_files(query)
if not agent_input.attachments:
agent_input.attachments = [
InputAttachment.model_validate(item)
for item in QueryEntryAdapter._build_attachments(query, [c.model_dump() for c in agent_input.contents])
]
components = []
for i, attachment in enumerate(agent_input.attachments):
kind = (attachment.type or 'file').lower()
fields = {'url': attachment.url or '', 'base64': attachment.content or ''}
cls = {'image': pm.Image, 'voice': pm.Voice}.get(kind, pm.File)
if cls is pm.File:
fields.update(name=attachment.name or '', id=attachment.id or '')
component = cls(**fields)
# Match by payload, never by list position (contents and message chains
# can describe attachments in different orders).
for original in originals:
if type(original) is not cls:
continue
if (
(attachment.url and attachment.url == original.url)
or (attachment.content and attachment.content == original.base64)
or (
attachment.id
and attachment.id
== getattr(original, 'id', getattr(original, 'image_id', getattr(original, 'voice_id', None)))
)
or (
not attachment.url
and not attachment.content
and not attachment.id
and attachment.name
and attachment.name == getattr(original, 'name', None)
)
):
component = original
break
components.append(component)
attachment.ref, attachment.path = f'attachment-{i}', None
object.__setattr__(query, '_box_inputs', components)
def input_files(query):
prepared = getattr(query, '_box_inputs', None)
if prepared is not None:
return prepared
return [c for c in query.message_chain or [] if isinstance(c, (pm.Image, pm.Voice, pm.File))]
def binding_for(query) -> RunBoxBinding:
binding = getattr(query, '_box_binding', None)
if not isinstance(binding, RunBoxBinding):
raise BoxValidationError('Runner must bind a Box before using sandbox tools or files')
return binding
def exported_message(query, file_ids: list[str], *, consume: bool = False) -> pm.MessageChain:
binding = binding_for(query)
if not isinstance(file_ids, list) or len(file_ids) > 100 or any(type(x) is not str for x in file_ids):
raise BoxValidationError('file_ids must contain at most 100 exported file IDs')
if len(set(file_ids)) != len(file_ids):
raise BoxValidationError('Duplicate exported file ID')
if any(key not in binding.exported or key in binding.submitted for key in file_ids):
raise BoxValidationError('Output file does not belong to this run or has already been submitted')
components = []
for key in file_ids:
item = binding.exported[key]
if item['type'] == 'Image':
components.append(pm.Image(base64=item['base64']))
elif item['type'] == 'Voice':
components.append(pm.Voice(base64=item['base64']))
else:
components.append(pm.File(name=item['name'], base64=item['base64']))
if consume:
binding.submitted.update(file_ids)
return pm.MessageChain(components)
class RunnerBoxService:
def __init__(self, box):
self.box = box
async def status(self, context):
status = await self.box.get_status(context)
capacity = status.get('capacity', {})
return {
'enabled': self.box.enabled,
'available': status.get('available', False),
'limit': capacity.get('limit'),
'used': capacity.get('used'),
'remaining': capacity.get('remaining'),
'required_reuse_key': 'global' if self.box.managed_admission_required else None,
'reason': status.get('connector_error'),
}
async def sessions(self, context):
# Do not hide connection failures behind an empty list.
context = await self.box.require_workspace_sandbox(context)
return await self.box.client.get_sessions(action_context=self.box._action_context(context))
@staticmethod
def public_session(item):
return {'id': item['session_id'], 'status': item.get('status', 'idle')}
async def acquire(self, context, data, query=None):
request = BoxAcquireRequest.model_validate(data)
key = request.reuse_key
if self.box.managed_admission_required:
if key != 'global':
raise BoxValidationError('This deployment requires the global Box reuse key')
session_id = 'global'
else:
session_id = 'runner-' + hashlib.sha256(key.encode()).hexdigest()
# Plugin options cannot override policy, host mounts, or execution identity.
if set(request.options) - {'image'}:
raise BoxValidationError('Only the Box image can be specified; resource limits are Host-owned')
spec = {'session_id': session_id, **request.options}
if query is not None:
spec['extra_mounts'] = self.box.build_skill_extra_mounts(query)
result = await self.box.create_session(context, spec)
return self.public_session(result)
async def bind(self, context, query, run_id, box_id):
current = getattr(query, '_box_binding', None)
if current is not None:
if current.run_id != run_id or current.session_id != box_id:
raise BoxValidationError('A run cannot change its Box after binding')
return self.binding_info(current)
sessions = await self.sessions(context)
item = next((item for item in sessions if item['session_id'] == box_id), None)
if item is None:
raise BoxValidationError('Box not found in the current Workspace')
current = getattr(query, '_box_binding', None)
if current is not None:
if current.run_id != run_id or current.session_id != box_id:
raise BoxValidationError('A run cannot change its Box after binding')
return self.binding_info(current)
fields = ('image', 'network', 'cpus', 'memory_mb', 'pids_limit', 'read_only_rootfs', 'persistent')
spec = {key: item[key] for key in fields if key in item}
spec['extra_mounts'] = self.box.build_skill_extra_mounts(query)
binding = RunBoxBinding(run_id, box_id, spec, run_id)
object.__setattr__(query, '_box_binding', binding)
return self.binding_info(binding)
@staticmethod
def binding_info(binding):
return {
'box_id': binding.session_id,
'outbox': f'/workspace/outbox/{binding.io_scope}',
}
async def import_attachments(self, query, attachment_ids):
binding = binding_for(query)
available = {f'attachment-{i}': c for i, c in enumerate(input_files(query))}
ids = list(available) if attachment_ids is None else attachment_ids
if not isinstance(ids, list) or any(type(key) is not str or key not in available for key in ids):
raise BoxValidationError('Unknown input attachment reference')
if len(ids) != len(set(ids)):
raise BoxValidationError('Duplicate input attachment reference')
async with binding.lock:
for key in ids:
if key in binding.imported:
continue
transfer = copy.copy(query)
# Each input has its own directory: repeated imports cannot erase other files.
object.__setattr__(transfer, '_box_binding', replace(binding, io_scope=f'{binding.io_scope}-{key}'))
transfer.message_chain = pm.MessageChain([available[key]])
items = await self.box.materialize_inbound_attachments(transfer)
if not items:
raise BoxValidationError(f'Failed to import input attachment {key}')
binding.imported[key] = {'id': key, **items[0]}
return {'items': [binding.imported[key] for key in ids]}
async def export_files(self, query):
binding = binding_for(query)
async with binding.lock:
items = await self.box.collect_outbound_attachments(query)
for item in items:
if len(binding.exported) >= 100:
raise BoxValidationError('At most 100 output files may be exported per run')
size = len(base64.b64decode(item['base64'].split(';base64,')[-1]))
if sum(x['size'] for x in binding.exported.values()) + size > self.box._ATTACHMENT_MAX_TOTAL_BYTES:
raise BoxValidationError('Output files exceed the per-run byte limit')
key = str(uuid.uuid4())
binding.exported[key] = {
**item,
'id': key,
'size': size,
}
return {
'items': [
{k: v for k, v in item.items() if k != 'base64'}
for key, item in binding.exported.items()
if key not in binding.submitted
]
}
+22 -102
View File
@@ -38,8 +38,6 @@ _INT_ADAPTER = pydantic.TypeAdapter(int)
_UTC = _dt.timezone.utc
_MAX_RECENT_ERRORS = 50
_MIB = 1024 * 1024
_HOST_BOX_SCOPE_VARIABLE = '_host_box_scope'
_BOX_SESSION_ID_PREFIX = 'lb-box-'
_DEFAULT_MAX_WORKSPACE_ENTRIES = 100_000
_HARD_MAX_WORKSPACE_ENTRIES = 1_000_000
@@ -464,7 +462,9 @@ class BoxService:
async def _require_validated_workspace_sandbox(self, execution_context: ExecutionContext) -> None:
if not self._available:
raise BoxError('Box runtime is not available. Install and start Docker to use sandbox features.')
raise BoxError(
'Box runtime is not available. Configure an available Box backend before using Box features.'
)
if self._cloud_managed:
if self._admission is None:
raise BoxAdmissionError('Cloud Box sandbox admission is unavailable')
@@ -579,7 +579,15 @@ class BoxService:
skip_host_mount_validation: bool = False,
) -> dict:
if not self._available:
raise BoxError('Box runtime is not available. Install and start Docker to use sandbox features.')
raise BoxError(
'Box runtime is not available. Configure an available Box backend before using Box features.'
)
from .runner import binding_for
binding = binding_for(query)
if spec_payload.get('session_id') not in (None, '', binding.session_id):
raise BoxValidationError('session_id must match the bound Box')
spec_payload = {**binding.spec, **spec_payload, 'session_id': binding.session_id}
execution_context = await self._validated_execution_context(self._query_execution_context(query))
spec_payload = self._managed_policy_payload(execution_context, spec_payload)
await self._require_validated_workspace_sandbox(execution_context)
@@ -632,49 +640,10 @@ class BoxService:
return self._serialize_result(result)
def resolve_box_session_id(self, query: pipeline_query.Query) -> str:
"""Resolve a Host-owned Box session ID for the current conversation."""
if query is None:
raise BoxValidationError('Box execution requires a Host session context.')
"""Use the Box explicitly bound by the current Runner invocation."""
from .runner import binding_for
if self._cloud_managed:
return 'global'
variables = getattr(query, 'variables', None)
if isinstance(variables, dict) and _HOST_BOX_SCOPE_VARIABLE in variables:
private_scope = variables[_HOST_BOX_SCOPE_VARIABLE]
if not isinstance(private_scope, str) or not private_scope.strip():
raise BoxValidationError('Box execution requires a Host conversation scope.')
return self._hash_box_session_scope(f'host:{private_scope}')
forced_template = self._forced_box_session_id_template()
if forced_template:
template = forced_template
else:
template = (
(query.pipeline_config or {})
.get('ai', {})
.get('local-agent', {})
.get('box-session-id-template', '{launcher_type}_{launcher_id}')
)
variables = dict(query.variables or {})
launcher_type = getattr(query, 'launcher_type', None)
launcher_id = getattr(query, 'launcher_id', None)
if hasattr(launcher_type, 'value'):
launcher_type = launcher_type.value
sender_id = getattr(query, 'sender_id', None)
query_id = getattr(query, 'query_id', None)
variables.setdefault('query_id', str(query_id or 'unknown'))
variables.setdefault('launcher_type', str(launcher_type or 'query'))
variables.setdefault('launcher_id', str(launcher_id or query_id or 'unknown'))
variables.setdefault('sender_id', str(sender_id or launcher_id or query_id or 'unknown'))
variables.setdefault('global', 'global')
return template.format_map(collections.defaultdict(lambda: 'unknown', variables))
@staticmethod
def _hash_box_session_scope(scope: str) -> str:
digest = hashlib.sha256(scope.encode('utf-8')).hexdigest()
return f'{_BOX_SESSION_ID_PREFIX}{digest}'
return binding_for(query).session_id
def build_skill_extra_mounts(self, query: pipeline_query.Query) -> list[dict]:
"""Build extra_mounts entries for all pipeline-bound skills.
@@ -822,6 +791,9 @@ class BoxService:
_EXEC_FALLBACK_MAX_BYTES = 256 * 1024
def _attachment_query_key(self, query: pipeline_query.Query) -> str:
binding = getattr(query, '_box_binding', None)
if binding is not None:
return binding.io_scope
query_uuid = str(getattr(query, 'query_uuid', '') or '').strip()
if query_uuid:
if query_uuid in {'.', '..'} or '/' in query_uuid or '\\' in query_uuid or '\x00' in query_uuid:
@@ -1833,16 +1805,6 @@ class BoxService:
raw = str(self._local_config().get('image', '') or '').strip()
return raw or None
def _forced_box_session_id_template(self) -> str:
"""Return the operator-forced sandbox scope template, if configured."""
limitation = (
(self.ap.instance_config.data or {}).get('system', {}).get('limitation', {})
if getattr(self.ap, 'instance_config', None) is not None
else {}
)
return str(limitation.get('force_box_session_id_template', '') or '').strip()
def _load_workspace_quota_mb(self) -> int | None:
raw_value = self._local_config().get('workspace_quota_mb')
if raw_value in (None, ''):
@@ -2069,51 +2031,6 @@ class BoxService:
and error.get('workspace_uuid') == execution_context.workspace_uuid
]
def get_system_guidance(self, query: pipeline_query.Query | int | str | None = None) -> str:
"""Return LLM system-prompt guidance for the exec tool.
All execution-specific prompt text is kept here so that callers
(e.g. LocalRunner) stay free of box domain knowledge.
``query`` is the current turn's pipeline query. When provided,
the guidance ALWAYS advertises the per-query outbox path so the agent
knows how to deliver generated files back to the user — even on turns
where the user sent no inbound attachment (e.g. "generate a QR code"),
which is exactly when the inbound-attachment note never fires. Outbound
collection in the wrapper runs on every turn regardless of inbound
files, so without this the file would be produced and silently dropped.
"""
guidance = (
'When the exec tool is available, use it for exact calculations, statistics, structured data parsing, '
'and code execution instead of estimating mentally. If the user provides numbers, tables, CSV-like text, '
'JSON, or other data and asks for a computed answer, prefer running a short Python script via exec '
'and then answer from the tool result. Unless the user explicitly asks for the script, code, or implementation '
'details, do not include the generated script in the final answer; return the result and a brief explanation only.'
)
if self.default_workspace:
guidance += (
' A default workspace is mounted at /workspace for file tasks. When the user asks to read, create, or '
'modify local files in the working directory, use exec with /workspace paths directly; do not ask the '
'user for directory parameters unless they explicitly need a different directory.'
)
if query is not None:
if not isinstance(query, (int, str)):
query_key = self._attachment_query_key(query)
else:
# Backwards compatibility for OSS callers/tests that passed
# the old process-local integer identity. Cloud callers must
# pass the full Query so an opaque UUID is always advertised.
if self._cloud_managed:
raise BoxValidationError('Cloud outbox guidance requires a pipeline Query')
query_key = str(query)
outbox_dir = f'{self.OUTBOX_MOUNT_DIR}/{query_key}'
guidance += (
f' If you produce any file (image, audio, document, etc.) that should be sent back to the user, '
f'write it into {outbox_dir}/ (create the directory if needed). Every file placed there will be '
'delivered to the user automatically; do not paste file contents or base64 into your reply.'
)
return guidance
async def get_backend_status(self) -> dict:
"""Return instance-level backend readiness without tenant resource data."""
@@ -2170,5 +2087,8 @@ class BoxService:
if backend_name:
payload['connector_error'] = f'Configured sandbox backend "{backend_name}" is unavailable'
else:
payload['connector_error'] = 'No supported sandbox backend (Docker / nsjail / E2B) is available'
payload['connector_error'] = (
'No supported sandbox backend (Docker / nsjail / E2B) is available. '
'Trusted local development may explicitly select the unsafe host backend.'
)
return payload
+100 -2
View File
@@ -125,10 +125,21 @@ class DirectoryProjectionService:
# The database cursor remains the shared projection high-water mark,
# while this cursor tracks what this process has actually observed.
self._consumer_cursor: int | None = None
self._sync_lock = asyncio.Lock()
async def initialize(self) -> None:
"""Block Cloud startup until one full signed snapshot is committed."""
async with self._sync_lock:
await self._refresh_snapshot()
async def refresh_snapshot(self) -> None:
"""Refresh from one full signed snapshot within the sync single-flight."""
async with self._sync_lock:
await self._refresh_snapshot()
async def _refresh_snapshot(self) -> None:
last_superseded: _DirectorySnapshotSuperseded | None = None
for _attempt in range(5):
snapshot = await self.provider.fetch_snapshot(self.instance_uuid)
@@ -159,9 +170,84 @@ class DirectoryProjectionService:
delay = min(max(delay * 2, self.sync_interval_seconds), self.max_staleness_seconds / 2)
async def sync_once(self) -> None:
async with self._sync_lock:
await self._sync_once()
async def reconcile_workspaces(self, workspace_uuids: Iterable[str]) -> None:
"""Synchronously project an exact Workspace set without moving the event cursor."""
requested = tuple(sorted({str(value).strip() for value in workspace_uuids if str(value).strip()}))
if not requested:
raise DirectoryProjectionUnavailableError('Targeted directory reconciliation requires a Workspace')
if len(requested) > self.event_limit:
raise DirectoryProjectionUnavailableError('Targeted directory reconciliation exceeds the batch limit')
async with self._sync_lock:
delta = await self.provider.fetch_workspaces(self.instance_uuid, requested)
await self._apply_targeted_delta(delta, requested)
async def _apply_targeted_delta(
self,
delta: DirectoryDelta,
requested_workspace_uuids: tuple[str, ...],
) -> None:
if not isinstance(delta, DirectoryDelta):
raise DirectoryProjectionUnavailableError('Directory provider returned an invalid delta')
workspace_count, membership_count = self._validate_batch_capacity(
delta.workspaces,
full_snapshot=False,
)
delta = DirectoryDelta.model_validate(delta.model_dump())
if delta.instance_uuid != self.instance_uuid:
raise DirectoryProjectionUnavailableError('Directory delta targets another LangBot instance')
requested = set(requested_workspace_uuids)
if set(delta.requested_workspace_uuids) != requested:
raise DirectoryProjectionUnavailableError('Directory delta does not match the requested Workspaces')
if {workspace.uuid for workspace in delta.workspaces} != requested:
raise DirectoryProjectionUnavailableError('Directory delta omitted a requested Workspace')
directory_uow = getattr(self.ap.persistence_mgr, 'directory_projection_uow', None)
if not callable(directory_uow):
raise DirectoryProjectionUnavailableError('Directory projection persistence scope is unavailable')
async with directory_uow(self.instance_uuid) as uow:
session = uow.session
state = await session.scalar(
sqlalchemy.select(DirectoryProjectionState)
.where(DirectoryProjectionState.instance_uuid == self.instance_uuid)
.with_for_update()
)
if state is None:
raise DirectoryProjectionUnavailableError('Directory projection is not initialized')
snapshot = DirectorySnapshot(
instance_uuid=self.instance_uuid,
cursor=state.cursor,
generated_at=delta.generated_at,
workspaces=delta.workspaces,
)
accounts_by_uuid = await self._apply_accounts(session, snapshot, preserve_existing=True)
await self._apply_workspaces(session, snapshot, accounts_by_uuid=accounts_by_uuid)
active_workspace_count = await self._enforce_active_workspace_capacity(session)
await session.flush()
await self._update_entitlement_workspace_activity(
snapshot.workspaces,
requested_workspace_uuids=requested,
)
self._publish_runtime_execution_projection(
snapshot.workspaces,
affected_workspace_uuids=requested,
)
self._request_model_catalog_sync()
self._record_batch_cardinality(
active_workspaces=active_workspace_count,
workspaces=workspace_count,
memberships=membership_count,
)
async def _sync_once(self) -> None:
cursor = self._consumer_cursor
if cursor is None:
await self.initialize()
await self._refresh_snapshot()
return
batch = await self.provider.fetch_events(
self.instance_uuid,
@@ -708,7 +794,13 @@ class DirectoryProjectionService:
for row in inbox_rows:
row.applied_at = now
async def _apply_accounts(self, session: Any, snapshot: DirectorySnapshot) -> dict[str, User]:
async def _apply_accounts(
self,
session: Any,
snapshot: DirectorySnapshot,
*,
preserve_existing: bool = False,
) -> dict[str, User]:
selected: dict[str, DirectoryMember] = {}
emails: dict[str, str] = {}
for workspace in snapshot.workspaces:
@@ -773,6 +865,12 @@ class DirectoryProjectionService:
continue
if account.source != AccountSource.CLOUD_PROJECTION.value:
raise DirectoryProjectionUnavailableError('Directory account UUID collides with a local Core account')
if preserve_existing:
# A targeted Workspace fetch has no independently monotonic
# Account revision. It may create a missing runtime shadow, but
# ordered event/snapshot projection remains the only updater of
# existing Account identity and status fields.
continue
if account.projection_revision > snapshot.cursor:
raise DirectoryProjectionUnavailableError('Directory account revision rolled back')
projected_account = self._account_projection(member)
+2 -2
View File
@@ -653,9 +653,9 @@ class Application:
frontend_path = paths.get_frontend_path()
if not os.path.exists(frontend_path):
self.logger.warning('WebUI 文件缺失,请根据文档部署:https://docs.langbot.app/zh')
self.logger.warning('WebUI 文件缺失,请根据文档部署:https://langbot.app/docs/zh')
self.logger.warning(
'WebUI files are missing, please deploy according to the documentation: https://docs.langbot.app/en'
'WebUI files are missing, please deploy according to the documentation: https://langbot.app/docs/en'
)
return
+1
View File
@@ -43,6 +43,7 @@ required_deps = {
'slack_sdk': 'slack_sdk',
'asyncpg': 'asyncpg',
'litellm': 'litellm',
'xxhash': 'xxhash',
}
+20 -1
View File
@@ -1,9 +1,18 @@
from __future__ import annotations
import logging
import secrets
from .. import stage, app
# This stage runs before SetupLoggerStage, so ap.logger is still None here;
# the module logger falls back to the stderr lastResort handler.
_logger = logging.getLogger(__name__)
# 32 symbols without 0/O or 1/I; eight independent draws provide 40 random bits.
_RECOVERY_KEY_ALPHABET = '23456789ABCDEFGHJKLMNPQRSTUVWXYZ'
_RECOVERY_KEY_LENGTH = 8
@stage.stage_class('GenKeysStage')
class GenKeysStage(stage.BootingStage):
@@ -20,5 +29,15 @@ class GenKeysStage(stage.BootingStage):
ap.instance_config.data['system']['recovery_key'] = ''
if not ap.instance_config.data['system']['recovery_key']:
ap.instance_config.data['system']['recovery_key'] = secrets.token_hex(3).upper()
# Keep recovery practical to type. Security also requires the reset
# endpoint's concurrency-safe quota (five admissions per 15 minutes).
ap.instance_config.data['system']['recovery_key'] = ''.join(
secrets.choice(_RECOVERY_KEY_ALPHABET) for _ in range(_RECOVERY_KEY_LENGTH)
)
await ap.instance_config.dump_config()
elif len(ap.instance_config.data['system']['recovery_key']) < _RECOVERY_KEY_LENGTH:
_logger.warning(
'Low-entropy legacy recovery key detected (length < 8); '
'regenerate system.recovery_key in the configuration file '
'with a strong random value (#2392)'
)
+49 -12
View File
@@ -1,6 +1,7 @@
from __future__ import annotations
import asyncio
import json
import typing
import datetime
import time
@@ -197,6 +198,41 @@ class TaskWrapper:
},
}
def to_public_dict(self) -> dict:
"""Return the stable task projection exposed to API-key callers."""
if self.task.cancelled():
status = 'cancelled'
error = {'type': 'task_cancelled', 'message': 'Task was cancelled'}
result = None
elif not self.task.done():
status = 'running'
error = None
result = None
else:
exception = self.assume_exception()
if exception is not None:
status = 'failed'
error = {'type': 'task_failed', 'message': 'Task execution failed'}
result = None
else:
status = 'succeeded'
error = None
result = self.assume_result()
try:
json.dumps(result)
except (TypeError, ValueError):
result = None
return {
'id': self.id,
'task_type': self.task_type,
'kind': self.kind,
'status': status,
'error': error,
'result': result,
'created_at': self.created_at,
}
def cancel(self):
self.task.cancel()
@@ -325,19 +361,20 @@ class AsyncTaskManager:
instance_uuid: str | None = None,
workspace_uuid: str | None = None,
placement_generation: int | None = None,
public: bool = False,
) -> dict:
return {
'tasks': [
t.to_dict()
for t in self.tasks
if (type is None or t.task_type == type)
and (kind is None or t.kind == kind)
and (instance_uuid is None or t.instance_uuid == instance_uuid)
and (workspace_uuid is None or t.workspace_uuid == workspace_uuid)
and (placement_generation is None or t.placement_generation == placement_generation)
],
'id_index': TaskWrapper._id_index,
}
tasks = [
t.to_public_dict() if public else t.to_dict()
for t in self.tasks
if (type is None or t.task_type == type)
and (kind is None or t.kind == kind)
and (instance_uuid is None or t.instance_uuid == instance_uuid)
and (workspace_uuid is None or t.workspace_uuid == workspace_uuid)
and (placement_generation is None or t.placement_generation == placement_generation)
]
if public:
return {'tasks': tasks}
return {'tasks': tasks, 'id_index': TaskWrapper._id_index}
def get_stats(self) -> dict:
completed = sum(1 for t in self.tasks if t.task.done())
@@ -33,6 +33,28 @@ class ModelProvider(Base):
)
class CodexCredential(Base):
"""Server-only OAuth state. Never joined into provider/model serialization."""
__tablename__ = 'codex_credentials'
provider_uuid = sqlalchemy.Column(sqlalchemy.String(255), primary_key=True)
workspace_uuid = sqlalchemy.Column(sqlalchemy.String(36), nullable=False)
payload = sqlalchemy.Column(sqlalchemy.JSON, nullable=False, default=dict)
version = sqlalchemy.Column(sqlalchemy.Integer, nullable=False, default=0)
lease_owner = sqlalchemy.Column(sqlalchemy.String(64), nullable=True)
lease_until = sqlalchemy.Column(sqlalchemy.Float, nullable=False, default=0)
__table_args__ = (
sqlalchemy.ForeignKeyConstraint(
['workspace_uuid', 'provider_uuid'],
['model_providers.workspace_uuid', 'model_providers.uuid'],
name='fk_codex_credentials_workspace_provider',
ondelete='CASCADE',
),
sqlalchemy.Index('ix_codex_credentials_workspace', 'workspace_uuid'),
)
class LLMModel(Base):
"""LLM model"""
@@ -111,8 +111,8 @@ class MonitoringSession(Base):
sqlalchemy.ForeignKey('workspaces.uuid', ondelete='CASCADE'),
primary_key=True,
)
bot_id = sqlalchemy.Column(sqlalchemy.String(255), primary_key=True, index=True)
session_id = sqlalchemy.Column(sqlalchemy.String(255), primary_key=True)
bot_id = sqlalchemy.Column(sqlalchemy.String(255), nullable=False, index=True)
bot_name = sqlalchemy.Column(sqlalchemy.String(255), nullable=False)
pipeline_id = sqlalchemy.Column(sqlalchemy.String(255), nullable=False, index=True)
pipeline_name = sqlalchemy.Column(sqlalchemy.String(255), nullable=False)
@@ -0,0 +1,38 @@
from __future__ import annotations
import uuid as uuid_lib
import sqlalchemy
from .base import Base
class PasskeyCredential(Base):
__tablename__ = 'passkey_credentials'
id = sqlalchemy.Column(sqlalchemy.Integer, primary_key=True, autoincrement=True)
uuid = sqlalchemy.Column(
sqlalchemy.String(36),
nullable=False,
default=lambda: str(uuid_lib.uuid4()),
)
account_uuid = sqlalchemy.Column(
sqlalchemy.String(36),
sqlalchemy.ForeignKey('users.uuid', ondelete='CASCADE'),
nullable=False,
)
name = sqlalchemy.Column(sqlalchemy.String(255), nullable=False)
credential_id = sqlalchemy.Column(sqlalchemy.String(255), nullable=False)
public_key = sqlalchemy.Column(sqlalchemy.Text, nullable=False)
sign_count = sqlalchemy.Column(sqlalchemy.Integer, nullable=False, default=0)
aaguid = sqlalchemy.Column(sqlalchemy.String(64), nullable=True)
transports = sqlalchemy.Column(sqlalchemy.String(255), nullable=True)
backed_up = sqlalchemy.Column(sqlalchemy.Boolean, nullable=False, default=False)
created_at = sqlalchemy.Column(sqlalchemy.DateTime, nullable=False, server_default=sqlalchemy.func.now())
last_used_at = sqlalchemy.Column(sqlalchemy.DateTime, nullable=True)
__table_args__ = (
sqlalchemy.Index('uq_passkey_credentials_uuid', 'uuid', unique=True),
sqlalchemy.Index('uq_passkey_credentials_cred_id', 'credential_id', unique=True),
sqlalchemy.Index('ix_passkey_credentials_account', 'account_uuid'),
)
@@ -0,0 +1,33 @@
"""Tenant-owned original configurations; never expose through public serialization."""
import sqlalchemy as sa
from .base import Base
class PipelineMigrationSnapshot(Base):
__tablename__ = 'pipeline_migration_snapshots'
uuid = sa.Column(sa.String(36), primary_key=True)
workspace_uuid = sa.Column(sa.String(36), sa.ForeignKey('workspaces.uuid', ondelete='CASCADE'), nullable=False)
pipeline_uuid = sa.Column(sa.String(255), nullable=False)
source_fingerprint = sa.Column(sa.String(64), nullable=False)
target_fingerprint = sa.Column(sa.String(64), nullable=False)
planner_version = sa.Column(sa.String(64), nullable=False)
source_snapshot = sa.Column(sa.JSON, nullable=False)
state = sa.Column(sa.String(32), nullable=False)
created_at = sa.Column(sa.DateTime, nullable=False, server_default=sa.func.now())
__table_args__ = (
sa.ForeignKeyConstraint(
['workspace_uuid', 'pipeline_uuid'],
['legacy_pipelines.workspace_uuid', 'legacy_pipelines.uuid'],
name='fk_pipeline_migration_workspace_pipeline',
ondelete='CASCADE',
),
sa.UniqueConstraint(
'workspace_uuid', 'pipeline_uuid', 'source_fingerprint', name='uq_pipeline_migration_source'
),
sa.CheckConstraint("state IN ('activation_pending', 'active')", name='ck_pipeline_migration_state'),
sa.Index('ix_pipeline_migration_workspace_pipeline', 'workspace_uuid', 'pipeline_uuid'),
)
@@ -0,0 +1,48 @@
"""Add isolated server-only Codex credentials and tenant RLS.
Revision ID: 0022_codex_credentials
Revises: 0021_merge_reasoning_config
"""
from alembic import op
import sqlalchemy as sa
revision = '0022_codex_credentials'
down_revision = '0021_merge_reasoning_config'
branch_labels = None
depends_on = None
def upgrade() -> None:
conn = op.get_bind()
# Fresh startup creates ORM metadata before running Alembic.
if 'codex_credentials' not in sa.inspect(conn).get_table_names():
op.create_table(
'codex_credentials',
sa.Column('provider_uuid', sa.String(255), primary_key=True),
sa.Column('workspace_uuid', sa.String(36), nullable=False),
sa.Column('payload', sa.JSON(), nullable=False),
sa.Column('version', sa.Integer(), nullable=False),
sa.Column('lease_owner', sa.String(64), nullable=True),
sa.Column('lease_until', sa.Float(), nullable=False),
sa.ForeignKeyConstraint(
['workspace_uuid', 'provider_uuid'],
['model_providers.workspace_uuid', 'model_providers.uuid'],
name='fk_codex_credentials_workspace_provider',
ondelete='CASCADE',
),
)
op.create_index('ix_codex_credentials_workspace', 'codex_credentials', ['workspace_uuid'])
if conn.dialect.name == 'postgresql':
op.execute('ALTER TABLE codex_credentials ENABLE ROW LEVEL SECURITY')
op.execute('ALTER TABLE codex_credentials FORCE ROW LEVEL SECURITY')
op.execute('DROP POLICY IF EXISTS langbot_workspace_isolation ON codex_credentials')
expression = "workspace_uuid::text = NULLIF(current_setting('langbot.workspace_uuid', true), '')"
op.execute(
f'CREATE POLICY langbot_workspace_isolation ON codex_credentials '
f'FOR ALL USING ({expression}) WITH CHECK ({expression})'
)
def downgrade() -> None:
op.drop_table('codex_credentials')
@@ -0,0 +1,104 @@
"""Scope monitoring sessions by bot without changing runtime session IDs.
Revision ID: 0023_bot_scoped_sessions
Revises: 0022_codex_credentials
"""
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import postgresql, sqlite
revision = '0023_bot_scoped_sessions'
down_revision = '0022_codex_credentials'
branch_labels = None
depends_on = None
_TABLE = 'monitoring_sessions'
_KEY = ['workspace_uuid', 'bot_id', 'session_id']
def upgrade() -> None:
conn = op.get_bind()
inspector = sa.inspect(conn)
if _TABLE not in inspector.get_table_names():
return
pk = inspector.get_pk_constraint(_TABLE)
if pk['constrained_columns'] == _KEY:
return
# PostgreSQL alters in place, retaining indexes, grants, policies and RLS.
# SQLite batch reflection retains all existing indexes and foreign keys.
with op.batch_alter_table(_TABLE, naming_convention={'pk': 'pk_%(table_name)s'}) as batch:
batch.drop_constraint(pk['name'] or f'pk_{_TABLE}', type_='primary')
batch.create_primary_key(f'pk_{_TABLE}', _KEY)
metadata = sa.MetaData()
sessions = sa.Table(_TABLE, metadata, autoload_with=conn)
messages = sa.Table('monitoring_messages', metadata, autoload_with=conn)
m = messages.c
collisions = (
sa.select(m.workspace_uuid, m.session_id)
.group_by(m.workspace_uuid, m.session_id)
.having(sa.func.count(sa.distinct(m.bot_id)) > 1)
.subquery()
)
partition = [m.workspace_uuid, m.bot_id, m.session_id]
# Repair only demonstrable collisions. Retention may have removed earlier
# evidence; these summaries describe surviving messages, never invented text.
ranked = (
sa.select(
*[m[name] for name in _KEY],
m.bot_name,
m.pipeline_id,
m.pipeline_name,
m.platform,
m.user_id,
m.user_name,
sa.func.sum(sa.case((sa.or_(m.role == 'user', m.role.is_(None)), 1), else_=0))
.over(partition_by=partition)
.label('message_count'),
sa.func.min(m.timestamp).over(partition_by=partition).label('start_time'),
sa.func.max(m.timestamp).over(partition_by=partition).label('last_activity'),
sa.func.row_number().over(partition_by=partition, order_by=[m.timestamp.desc(), m.id.desc()]).label('rank'),
)
.join(
collisions,
sa.and_(m.workspace_uuid == collisions.c.workspace_uuid, m.session_id == collisions.c.session_id),
)
.subquery()
)
columns = _KEY + [
'bot_name',
'pipeline_id',
'pipeline_name',
'platform',
'user_id',
'user_name',
'message_count',
'start_time',
'last_activity',
'is_active',
]
select = sa.select(*[ranked.c[name] for name in columns[:-1]], sa.literal(True)).where(ranked.c.rank == 1)
insert = postgresql.insert if conn.dialect.name == 'postgresql' else sqlite.insert
statement = insert(sessions).from_select(columns, select)
conn.execute(
statement.on_conflict_do_update(
index_elements=_KEY,
set_={name: statement.excluded[name] for name in columns if name not in _KEY and name != 'is_active'},
)
)
def downgrade() -> None:
conn = op.get_bind()
if _TABLE not in sa.inspect(conn).get_table_names():
return
collisions = conn.execute(
sa.text('SELECT 1 FROM monitoring_sessions GROUP BY workspace_uuid, session_id HAVING COUNT(*) > 1 LIMIT 1')
).first()
if collisions:
raise RuntimeError('Cannot downgrade bot-scoped sessions without losing colliding bot records')
pk = sa.inspect(conn).get_pk_constraint(_TABLE)
with op.batch_alter_table(_TABLE, naming_convention={'pk': 'pk_%(table_name)s'}) as batch:
batch.drop_constraint(pk['name'] or f'pk_{_TABLE}', type_='primary')
batch.create_primary_key(f'pk_{_TABLE}', ['workspace_uuid', 'session_id'])
@@ -0,0 +1,54 @@
"""add passkey credentials table
Revision ID: 0024_passkey_credentials
Revises: 0023_bot_scoped_sessions
Create Date: 2026-09-12
"""
from __future__ import annotations
import sqlalchemy as sa
from alembic import op
revision = '0024_passkey_credentials'
down_revision = '0023_bot_scoped_sessions'
branch_labels = None
depends_on = None
_TABLE_NAME = 'passkey_credentials'
def upgrade() -> None:
conn = op.get_bind()
existing_tables = set(sa.inspect(conn).get_table_names())
if _TABLE_NAME not in existing_tables:
op.create_table(
_TABLE_NAME,
sa.Column('id', sa.Integer(), primary_key=True, autoincrement=True),
sa.Column('uuid', sa.String(36), nullable=False),
sa.Column(
'account_uuid',
sa.String(36),
sa.ForeignKey('users.uuid', ondelete='CASCADE'),
nullable=False,
),
sa.Column('name', sa.String(255), nullable=False),
sa.Column('credential_id', sa.String(255), nullable=False),
sa.Column('public_key', sa.Text(), nullable=False),
sa.Column('sign_count', sa.Integer(), nullable=False, server_default='0'),
sa.Column('aaguid', sa.String(64), nullable=True),
sa.Column('transports', sa.String(255), nullable=True),
sa.Column('backed_up', sa.Boolean(), nullable=False, server_default='0'),
sa.Column('created_at', sa.DateTime(), nullable=False, server_default=sa.func.now()),
sa.Column('last_used_at', sa.DateTime(), nullable=True),
)
op.create_index('uq_passkey_credentials_uuid', _TABLE_NAME, ['uuid'], unique=True)
op.create_index('uq_passkey_credentials_cred_id', _TABLE_NAME, ['credential_id'], unique=True)
op.create_index('ix_passkey_credentials_account', _TABLE_NAME, ['account_uuid'], unique=False)
def downgrade() -> None:
op.drop_index('ix_passkey_credentials_account', table_name=_TABLE_NAME)
op.drop_index('uq_passkey_credentials_cred_id', table_name=_TABLE_NAME)
op.drop_index('uq_passkey_credentials_uuid', table_name=_TABLE_NAME)
op.drop_table(_TABLE_NAME)
@@ -0,0 +1,22 @@
"""Join the published master credentials and 4.11 bot processor branches.
Revision ID: 0026_merge_master_beta
Revises: 0024_passkey_credentials, 0025_bot_plugin_processors
Both parent chains are already published. Keep their identities and schema/data
operations intact; Alembic applies the missing branch before this no-op merge.
"""
revision = '0026_merge_master_beta'
down_revision = ('0024_passkey_credentials', '0025_bot_plugin_processors')
branch_labels = None
depends_on = None
def upgrade() -> None:
pass
def downgrade() -> None:
# Only unmerge the revision graph; neither branch's schema/data is removed.
pass
@@ -0,0 +1,53 @@
"""Create the manual migration journal only; never convert pipeline JSON."""
from alembic import op
import sqlalchemy as sa
revision = '0027_pipeline_migration'
down_revision = '0026_merge_master_beta'
branch_labels = None
depends_on = None
def upgrade():
bind = op.get_bind()
table = 'pipeline_migration_snapshots'
if not sa.inspect(bind).has_table(table):
op.create_table(
table,
sa.Column('uuid', sa.String(36), primary_key=True),
sa.Column(
'workspace_uuid', sa.String(36), sa.ForeignKey('workspaces.uuid', ondelete='CASCADE'), nullable=False
),
sa.Column('pipeline_uuid', sa.String(255), nullable=False),
sa.Column('source_fingerprint', sa.String(64), nullable=False),
sa.Column('target_fingerprint', sa.String(64), nullable=False),
sa.Column('planner_version', sa.String(64), nullable=False),
sa.Column('source_snapshot', sa.JSON(), nullable=False),
sa.Column('state', sa.String(32), nullable=False),
sa.Column('created_at', sa.DateTime(), nullable=False, server_default=sa.func.now()),
sa.ForeignKeyConstraint(
['workspace_uuid', 'pipeline_uuid'],
['legacy_pipelines.workspace_uuid', 'legacy_pipelines.uuid'],
name='fk_pipeline_migration_workspace_pipeline',
ondelete='CASCADE',
),
sa.UniqueConstraint(
'workspace_uuid', 'pipeline_uuid', 'source_fingerprint', name='uq_pipeline_migration_source'
),
sa.CheckConstraint("state IN ('activation_pending', 'active')", name='ck_pipeline_migration_state'),
)
op.create_index('ix_pipeline_migration_workspace_pipeline', table, ['workspace_uuid', 'pipeline_uuid'])
if bind.dialect.name == 'postgresql':
op.execute(f'ALTER TABLE {table} ENABLE ROW LEVEL SECURITY')
op.execute(f'ALTER TABLE {table} FORCE ROW LEVEL SECURITY')
op.execute(f'DROP POLICY IF EXISTS langbot_workspace_isolation ON {table}')
expression = "workspace_uuid = NULLIF(current_setting('langbot.workspace_uuid', true), '')"
op.execute(
f'CREATE POLICY langbot_workspace_isolation ON {table} FOR ALL TO PUBLIC '
f'USING ({expression}) WITH CHECK ({expression})'
)
def downgrade():
op.drop_table('pipeline_migration_snapshots')
@@ -0,0 +1,19 @@
"""Join pipeline migration and knowledge base draft branches.
Revision ID: 0028_merge_knowledge_drafts
Revises: 0027_pipeline_migration, 0026_knowledge_base_drafts
"""
revision = '0028_merge_knowledge_drafts'
down_revision = ('0027_pipeline_migration', '0026_knowledge_base_drafts')
branch_labels = None
depends_on = None
def upgrade() -> None:
pass
def downgrade() -> None:
# Only unmerge the revision graph; neither branch's schema is removed.
pass
+3
View File
@@ -64,11 +64,14 @@ _ALEMBIC_TENANT_TABLES = {
'binary_storages',
'mcp_servers',
'model_providers',
'codex_credentials',
'passkey_credentials',
'llm_models',
'embedding_models',
'rerank_models',
'legacy_pipelines',
'pipeline_run_records',
'pipeline_migration_snapshots',
'plugin_settings',
'knowledge_bases',
'knowledge_base_files',
@@ -0,0 +1,27 @@
"""Transaction-held, tenant/pipeline-scoped admission for form producers.
Every pending producer must hold this row until its INSERT commits. Migration
holds the same row for both CAS and activation. No table/process/advisory lock.
The no-op UPDATE also acquires SQLite's write reservation before any reads.
"""
import sqlalchemy as sa
from ..entity.persistence.pipeline import LegacyPipeline
async def lock_pipeline_admission(session, workspace_uuid: str, pipeline_uuid: str):
if not workspace_uuid or not pipeline_uuid:
raise ValueError('Pipeline admission requires exact ownership')
t = LegacyPipeline.__table__
result = await session.execute(
sa.update(t)
.where(t.c.workspace_uuid == workspace_uuid, t.c.uuid == pipeline_uuid)
.values(updated_at=t.c.updated_at)
)
if result.rowcount != 1:
raise ValueError('Pipeline admission authority unavailable')
return (
await session.execute(
sa.select(t.c.config).where(t.c.workspace_uuid == workspace_uuid, t.c.uuid == pipeline_uuid)
)
).scalar_one()
+50 -3
View File
@@ -52,11 +52,13 @@ TENANT_TABLE_COLUMNS: dict[str, str] = {
'binary_storages': 'workspace_uuid',
'mcp_servers': 'workspace_uuid',
'model_providers': 'workspace_uuid',
'codex_credentials': 'workspace_uuid',
'llm_models': 'workspace_uuid',
'embedding_models': 'workspace_uuid',
'rerank_models': 'workspace_uuid',
'legacy_pipelines': 'workspace_uuid',
'pipeline_run_records': 'workspace_uuid',
'pipeline_migration_snapshots': 'workspace_uuid',
'plugin_settings': 'workspace_uuid',
'knowledge_bases': 'workspace_uuid',
'knowledge_base_files': 'workspace_uuid',
@@ -207,6 +209,8 @@ _SYNC_PROXY_CAPABILITY: contextvars.ContextVar[_ScopedSessionGuardState | None]
_ALLOWED_SCOPED_BUILTIN_FUNCTION_TYPES = {
'coalesce': sqlalchemy.sql.functions.coalesce,
'count': sqlalchemy.sql.functions.count,
'min': sqlalchemy.sql.functions.min,
'max': sqlalchemy.sql.functions.max,
'now': sqlalchemy.sql.functions.now,
'sum': sqlalchemy.sql.functions.sum,
}
@@ -464,9 +468,21 @@ def _validate_scoped_statement_call(args: tuple[typing.Any, ...], kwargs: dict[s
raise ScopedSessionTransactionError('TenantUnitOfWork does not allow literal-execute SQL parameters')
if isinstance(element, sqlalchemy.sql.elements.Cast) and type(element.type) not in {Vector, HALFVEC}:
raise ScopedSessionTransactionError(
'TenantUnitOfWork only allows the trusted pgvector cast used by tenant vector search'
# Manual Pipeline migration compares DB-native JSON text, because
# PostgreSQL JSON has no equality operator. Permit only these fixed
# mapped columns and a built-in Text target, never arbitrary casts.
source = element.clause
pipeline_json_cas = (
type(element.type) is sqlalchemy.Text
and isinstance(source, sqlalchemy.Column)
and type(source.type) is sqlalchemy.JSON
and getattr(getattr(source, 'table', None), 'name', None) == 'legacy_pipelines'
and source.name in {'config', 'stages', 'extensions_preferences'}
)
if not pipeline_json_cas:
raise ScopedSessionTransactionError(
'TenantUnitOfWork only allows trusted pgvector and Pipeline JSON CAS casts'
)
if isinstance(element, sqlalchemy.sql.functions.FunctionElement):
function_name = str(getattr(element, 'name', '')).casefold()
@@ -853,7 +869,30 @@ class TenantScopedAsyncSession(sqlalchemy_asyncio.AsyncSession):
self._require_owner_task()
self._enter_internal_access()
try:
await transaction.commit()
# Retain the actual connection before COMMIT: after a failed SQLite
# COMMIT the logical transaction is inactive, but the DBAPI writer
# can still hold PENDING/RESERVED locks. Session.close()/rollback()
# alone can then return that poisoned connection to the pool.
connection = await super().connection()
try:
await transaction.commit()
except BaseException as exc:
cleanup = asyncio.create_task(connection.invalidate())
# Invalidation does not access the task-owned Session. Shield
# physical cleanup, including against repeated cancellation,
# before the owner closes the Session and releases its scope.
while not cleanup.done():
try:
await asyncio.shield(cleanup)
except asyncio.CancelledError:
continue
except BaseException:
break
try:
cleanup.result()
except BaseException as cleanup_error:
exc.add_note(f'Failed to invalidate transaction connection: {cleanup_error!r}')
raise
finally:
self._exit_internal_access()
@@ -1370,6 +1409,7 @@ class TenantUnitOfWork:
state.mark_rollback_only(exc_value)
rollback_only = state.rollback_only
committed = False
transaction_error: BaseException | None = None
try:
if exc_type is None and not rollback_only:
await typing.cast(TenantScopedAsyncSession, session)._commit_owned_transaction(
@@ -1382,6 +1422,9 @@ class TenantUnitOfWork:
_UOW_SESSION_CONTROL_CAPABILITY,
transaction,
)
except BaseException as exc:
transaction_error = exc
raise
finally:
try:
if self._active_transaction is not None and self._context_token is not None:
@@ -1389,6 +1432,10 @@ class TenantUnitOfWork:
await typing.cast(TenantScopedAsyncSession, session)._close_owned_session(
_UOW_SESSION_CONTROL_CAPABILITY
)
except BaseException as cleanup_error:
if transaction_error is None:
raise
transaction_error.add_note(f'Failed to close transaction Session: {cleanup_error!r}')
finally:
if self._database_operation_token is not None:
_DATABASE_OPERATION_TRANSACTION.reset(self._database_operation_token)
@@ -0,0 +1,704 @@
"""Pure planning for explicitly requested legacy Pipeline migrations.
The candidate config is internal-only: never serialize it in a preview or log
it. ``ready`` means a config candidate, NOT permission to execute: installation,
resource ownership and existing conversation-state checks belong to the caller.
No IO, registry access, defaults from UI schemas, or runtime mutation occurs here.
Reviewed native source: 9b7ba0d64708496ace30a82866f6dbc185f089dc.
Reviewed official plugins: local parity patches over 678b18fd65af98230806c3b97b649514c83acea9.
Patch manifest versions below are required; version alone is not artifact proof.
"""
from __future__ import annotations
import copy
import json
import math
import re
from urllib.parse import urlsplit
from string import Formatter
PLANNER_VERSION = '3'
_TARGETS = {
'local-agent': ('LocalAgent', '0.1.7'),
'dify-service-api': ('DifyAgent', '0.1.7'),
'coze-api': ('CozeAgent', '0.1.7'),
'dashscope-app-api': ('DashScopeAgent', '0.1.7'),
'n8n-service-api': ('N8nAgent', '0.1.7'),
'langflow-api': ('LangflowAgent', '0.1.7'),
'deerflow-api': ('DeerFlowAgent', '0.1.7'),
'tbox-app-api': ('TboxAgent', '0.1.5'),
'weknora-api': ('WeKnoraAgent', '0.1.7'),
}
_DEERFLOW_FIELDS = {
'api-base',
'api-key',
'auth-header',
'assistant-id',
'model-name',
'thinking-enabled',
'plan-mode',
'subagent-enabled',
'max-concurrent-subagents',
'timeout',
'recursion-limit',
}
_FIELDS = {
'local-agent': {
'model',
'max-round',
'prompt',
'box-session-id-template',
'rerank-model',
'rerank-top-k',
'enable-all-tools',
'tools',
'knowledge-bases',
'knowledge-base',
'mcp-resources',
'mcp-resource-agent-read-enabled',
},
'dify-service-api': {'base-url', 'api-key', 'app-type', 'base-prompt', 'timeout'},
'coze-api': {'api-key', 'bot-id', 'api-base', 'timeout', 'auto-save-history', 'auto_save_history'},
'dashscope-app-api': {'app-type', 'api-key', 'app-id', 'references_quote', 'references-quote'},
'n8n-service-api': {
'webhook-url',
'auth-type',
'basic-username',
'basic-password',
'jwt-secret',
'jwt-algorithm',
'header-name',
'header-value',
'timeout',
'output-key',
'response-handling',
},
'langflow-api': {
'base-url',
'api-key',
'flow-id',
'input-type',
'output-type',
'input_type',
'output_type',
'tweaks',
},
'deerflow-api': _DEERFLOW_FIELDS,
'tbox-app-api': {'api-key', 'app-id'},
'weknora-api': {
'base-url',
'api-key',
'app-type',
'agent-id',
'knowledge-base-ids',
'web-search-enabled',
'timeout',
'base-prompt',
},
}
_REQUIRED = {
'local-agent': ('model', 'prompt'),
'dify-service-api': ('base-url', 'api-key', 'app-type', 'base-prompt'),
'coze-api': ('api-key', 'bot-id', 'api-base', 'timeout'),
'dashscope-app-api': ('app-type', 'api-key', 'app-id'),
'n8n-service-api': ('webhook-url',),
'langflow-api': ('base-url', 'api-key', 'flow-id'),
'deerflow-api': ('api-base',),
'tbox-app-api': ('api-key', 'app-id'),
'weknora-api': ('base-url', 'api-key', 'app-type'),
}
_BOOLEANS = {
'enable-all-tools',
'mcp-resource-agent-read-enabled',
'auto-save-history',
'auto_save_history',
'thinking-enabled',
'plan-mode',
'subagent-enabled',
'web-search-enabled',
}
_INTEGERS = {'max-round', 'rerank-top-k', 'max-concurrent-subagents', 'recursion-limit'}
_NAME_LISTS = {'tools', 'knowledge-bases', 'knowledge-base-ids'}
_DEERFLOW_DEFAULTS = {
'api-key': '',
'auth-header': '',
'assistant-id': 'lead_agent',
'model-name': '',
'thinking-enabled': False,
'plan-mode': False,
'subagent-enabled': False,
'max-concurrent-subagents': 3,
'timeout': 300,
'recursion-limit': 1000,
}
# Reviewed local patch manifests and implementations explicitly preserve native
# provider identity using trusted Host conversation fields; absent context fails
# closed in the plugin. Caller must verify the installed artifact/Host contract.
_IDENTITY_SOURCES = {
'dify-service-api': 'legacy-session',
'coze-api': 'legacy-session',
'n8n-service-api': 'legacy-session',
'tbox-app-api': 'legacy-bot',
}
_LOCAL_DEFAULTS = {
'advanced-settings': False,
'date-grounding': True,
'timeout': 300,
'retrieval-top-k': 5,
'rerank-model': '',
'rerank-top-k': 5,
'max-tool-iterations': 100,
'tool-execution-mode': 'serial',
'max-tool-result-chars': 20000,
'context-history-fetch-limit': 50,
'context-window-tokens': 200000,
'context-reserve-tokens': 16384,
'context-keep-recent-tokens': 20000,
'context-summary-tokens': 8000,
'enable-all-tools': True,
'tools': [],
'knowledge-bases': [],
}
_DEFAULTS = {
'local-agent': _LOCAL_DEFAULTS,
'dify-service-api': {'timeout': 30, 'advanced-settings': False},
'coze-api': {'auto-save-history': True, 'advanced-settings': False},
'dashscope-app-api': {'references_quote': '参考资料来自:', 'timeout': 120, 'advanced-settings': False},
'n8n-service-api': {
'auth-type': 'none',
'basic-username': '',
'basic-password': '',
'jwt-secret': '',
'jwt-algorithm': 'HS256',
'header-name': '',
'header-value': '',
'timeout': 120,
'output-key': 'response',
'response-handling': 'reply',
'basic-encoding': 'latin1',
'advanced-settings': False,
},
'langflow-api': {'input-type': 'chat', 'output-type': 'chat', 'tweaks': {}, 'advanced-settings': False},
'deerflow-api': _DEERFLOW_DEFAULTS,
'tbox-app-api': {'timeout': 120},
'weknora-api': {
'knowledge-base-ids': [],
'web-search-enabled': False,
'timeout': 120,
'base-prompt': '请回答用户的问题。',
'advanced-settings': False,
},
}
_ASSET_RUNNERS = {'dify-service-api', 'coze-api', 'dashscope-app-api', 'n8n-service-api', 'langflow-api'}
_REMOVE_THINK_RUNNERS = {'local-agent', 'dify-service-api', 'coze-api', 'dashscope-app-api', 'tbox-app-api'}
_REASONING_LEVELS = {'provider_default', 'disabled', 'enabled', 'minimal', 'low', 'medium', 'high', 'xhigh', 'max'}
def _strict_json(value, ancestors=None, depth=0):
"""Reject cycles, custom objects, nonfinite numbers and non-string keys."""
if depth > 100:
return False
kind = type(value)
if value is None or kind in (str, bool, int):
return True
if kind is float:
return math.isfinite(value)
if kind not in (dict, list):
return False
ancestors = set() if ancestors is None else ancestors
identity = id(value)
if identity in ancestors:
return False
ancestors.add(identity)
try:
if kind is dict:
return all(type(k) is str and _strict_json(v, ancestors, depth + 1) for k, v in value.items())
return all(_strict_json(v, ancestors, depth + 1) for v in value)
finally:
ancestors.remove(identity)
def _block(result, code, field):
"""Only developer-owned codes and paths may reach the public projection."""
diagnostic = {'code': code, 'field': field}
if diagnostic not in result['blockers']:
result['blockers'].append(diagnostic)
result['state'] = 'blocked'
return result
def _names(value):
return type(value) is list and all(type(item) is str and bool(item) for item in value)
def _attachments(value):
return type(value) is list and all(
type(item) is dict and ('enabled' not in item or type(item['enabled']) is bool) for item in value
)
def _validate_preferences(result, preferences, name):
if preferences is None:
return
if type(preferences) is not dict or not _strict_json(preferences):
_block(result, 'invalid_extension_preferences', 'extensions_preferences')
return
for field in (
'enable_all_plugins',
'enable_all_mcp_servers',
'enable_all_skills',
'mcp_resource_agent_read_enabled',
):
if field in preferences and type(preferences[field]) is not bool:
_block(result, 'invalid_extension_preferences', f'extensions_preferences.{field}')
for field in ('mcp_servers', 'skills'):
if field in preferences and not _names(preferences[field]):
_block(result, 'invalid_extension_preferences', f'extensions_preferences.{field}')
if 'mcp_resources' in preferences and not _attachments(preferences['mcp_resources']):
_block(result, 'invalid_extension_preferences', 'extensions_preferences.mcp_resources')
plugins = preferences.get('plugins', [])
if type(plugins) is not list or not all(
type(item) is dict and all(type(item.get(k)) is str and item[k] for k in ('author', 'name')) for item in plugins
):
_block(result, 'invalid_extension_preferences', 'extensions_preferences.plugins')
elif preferences.get('enable_all_plugins', True) is False and not any(
item['author'] == 'langbot-team' and item['name'] == name for item in plugins
):
# The immutable result contract contains no extension-preference patch.
# Require explicit binding instead of silently flipping allow-all.
_block(result, 'extensions.runner_excluded', 'extensions_preferences.plugins')
def _warn(result, code, field):
diagnostic = {'code': code, 'field': field}
if diagnostic not in result['warnings']:
result['warnings'].append(diagnostic)
def _message_shape(value, shape, required=()):
# SDK Message schemas, without importing runtime services or coercing values.
return (
type(value) is dict
and not (set(value) - set(shape))
and all(key in value for key in required)
and all(check(value[key]) for key, check in shape.items() if key in value)
)
def _valid_prompt(prompt):
def string(value):
return type(value) is str
def optional_string(value):
return value is None or string(value)
def metadata(value):
return value is None or type(value) is dict
def content(value):
if value is None or string(value):
return True
fields = {key: optional_string for key in ('text', 'image_base64', 'file_url', 'file_base64', 'file_name')}
fields.update(type=string, image_url=lambda v: v is None or _message_shape(v, {'url': string}, ('url',)))
return type(value) is list and all(_message_shape(v, fields, ('type',)) for v in value)
def tools(value):
fields = {
'id': string,
'type': string,
'function': lambda v: _message_shape(v, {'name': string, 'arguments': string}, ('name', 'arguments')),
'provider_specific_fields': metadata,
}
return value is None or (
type(value) is list and all(_message_shape(v, fields, ('id', 'type', 'function')) for v in value)
)
fields = {key: optional_string for key in ('name', 'tool_call_id', 'resp_message_id')}
fields.update(role=string, content=content, tool_calls=tools, provider_specific_fields=metadata)
return type(prompt) is list and all(_message_shape(item, fields, ('role',)) for item in prompt)
def _validate_local(result, section):
prefix = 'ai.local-agent'
model = section.get('model')
if type(model) is dict:
if set(model) - {'primary', 'fallbacks', 'reasoning'}:
_block(result, 'unknown_field', f'{prefix}.model')
if type(model.get('primary')) is not str or not model['primary'] or not _names(model.get('fallbacks', [])):
_block(result, 'invalid_type', f'{prefix}.model')
reasoning = model.get('reasoning', {})
if type(reasoning) is not dict or not all(type(v) is str for v in reasoning.values()):
_block(result, 'invalid_type', f'{prefix}.model.reasoning')
elif any(value not in _REASONING_LEVELS for value in reasoning.values()):
_block(result, 'local.reasoning_value', f'{prefix}.model')
elif reasoning:
_warn(result, 'local.model_reasoning', f'{prefix}.model.reasoning')
elif type(model) is not str or not model:
_block(result, 'invalid_type', f'{prefix}.model')
if not _valid_prompt(section.get('prompt')):
_block(result, 'local.prompt_shape', f'{prefix}.prompt')
_warn(result, 'local.context_defaults', f'{prefix}.max-round')
_warn(result, 'local.serial_tools_preserved', f'{prefix}.tools')
_warn(result, 'local.retrieval_defaults', f'{prefix}.knowledge-bases')
template = section.get('box-session-id-template', '{launcher_type}_{launcher_id}')
if type(template) is not str or not template.strip():
_block(result, 'local.box_template_invalid', f'{prefix}.box-session-id-template')
else:
try:
fields = list(Formatter().parse(template))
if any(
name is not None
and (not name or name.isdecimal() or re.search(r'[.\[\]{}]', name) or spec or conversion)
for _, name, spec, conversion in fields
):
raise ValueError('Only named interpolation variables are supported')
except ValueError:
_block(result, 'local.box_template_invalid', f'{prefix}.box-session-id-template')
_warn(result, 'local.box_state_reset', f'{prefix}.box-session-id-template')
def _unique_json_object(pairs):
result = {}
for key, value in pairs:
if key in result:
raise ValueError('duplicate_key')
result[key] = value
return result
def _parse_tweaks(raw):
if raw is None or (type(raw) is str and not raw.strip()):
return {}
decoded = json.loads(raw, object_pairs_hook=_unique_json_object) if type(raw) is str else raw
if decoded is None:
return {}
if type(decoded) is not dict or not _strict_json(decoded):
raise ValueError('invalid_tweaks')
return decoded
def _validate_external(result, legacy, section):
prefix = f'ai.{legacy}'
app_types = {
'dify-service-api': ('chat', 'agent', 'workflow', 'chatflow'),
'dashscope-app-api': ('agent', 'workflow'),
'weknora-api': ('chat', 'agent'),
}
required_strings = {
'dify-service-api': ('api-key', 'base-url'),
'coze-api': ('api-key', 'bot-id'),
'dashscope-app-api': ('api-key', 'app-id'),
'n8n-service-api': ('webhook-url',),
'langflow-api': ('base-url', 'api-key', 'flow-id'),
'tbox-app-api': ('api-key', 'app-id'),
'weknora-api': ('api-key', 'base-url'),
}
for field in required_strings.get(legacy, ()):
value = section.get(field)
if type(value) is str and (not value or (legacy == 'weknora-api' and not value.strip())):
_block(result, 'invalid_value', f'{prefix}.{field}')
if legacy in app_types and 'app-type' in section and section['app-type'] not in app_types[legacy]:
_block(result, 'invalid_value', f'{prefix}.app-type')
if legacy == 'coze-api':
actual, alias = section.get('auto_save_history'), section.get('auto-save-history')
if type(actual) is bool and type(alias) is bool and actual != alias:
_block(result, 'coze.history_alias', f'{prefix}.auto_save_history')
if 'auto_save_history' in section or 'auto-save-history' in section:
_warn(result, 'migration.alias_repaired', f'{prefix}.auto_save_history')
if actual is None and alias is None:
_warn(result, 'migration.null_default', f'{prefix}.auto_save_history')
base = section.get('api-base')
try:
parsed = urlsplit(base) if type(base) is str else None
if (
parsed is None
or parsed.scheme not in ('http', 'https')
or not parsed.hostname
or parsed.username is not None
or parsed.password is not None
or parsed.query
or parsed.fragment
or any(c.isspace() or ord(c) < 32 for c in base)
):
raise ValueError
_ = parsed.port
except ValueError:
_block(result, 'coze.custom_endpoint', f'{prefix}.api-base')
_warn(result, 'coze.persistent_history', prefix)
elif legacy == 'dashscope-app-api':
if 'references-quote' in section:
if 'references_quote' in section and section['references_quote'] != section['references-quote']:
_block(result, 'dashscope.references_alias', f'{prefix}.references_quote')
else:
_warn(result, 'migration.alias_repaired', f'{prefix}.references-quote')
elif legacy == 'langflow-api':
for axis in ('input', 'output'):
actual, ui = f'{axis}_type', f'{axis}-type'
if ui in section and section[ui] != section.get(actual, 'chat'):
_block(result, 'langflow.io_alias', f'{prefix}.{actual}')
if actual in section:
_warn(result, 'migration.alias_repaired', f'{prefix}.{actual}')
try:
_parse_tweaks(section.get('tweaks'))
except (ValueError, RecursionError):
_block(result, 'langflow.invalid_tweaks', f'{prefix}.tweaks')
if section.get('tweaks') is None or (
type(section.get('tweaks')) is str
and (not section['tweaks'].strip() or section['tweaks'].strip() == 'null')
):
_warn(result, 'langflow.tweaks_default', f'{prefix}.tweaks')
_warn(result, 'langflow.persistent_history', prefix)
elif legacy == 'dify-service-api':
_warn(result, 'dify.timeout_default', f'{prefix}.timeout')
elif legacy == 'n8n-service-api':
mode = section.get('auth-type', 'none')
auth_fields = {
'none': (),
'basic': ('basic-username', 'basic-password'),
'jwt': ('jwt-secret',),
'header': ('header-name', 'header-value'),
}
if type(mode) is not str or mode not in auth_fields:
_block(result, 'invalid_value', f'{prefix}.auth-type')
else:
for field in auth_fields[mode]:
if field not in section:
_block(result, 'missing_field', f'{prefix}.{field}')
response = section.get('response-handling', 'reply')
if response not in ('reply', 'ignore'):
_block(result, 'invalid_value', f'{prefix}.response-handling')
if mode == 'basic':
for field in ('basic-username', 'basic-password'):
value = section.get(field)
if type(value) is str:
try:
value.encode('latin1')
if field == 'basic-username' and ':' in value:
raise ValueError
except (UnicodeEncodeError, ValueError):
_block(result, 'n8n.basic_encoding', f'{prefix}.{field}')
if mode == 'header' and type(section.get('header-name')) is str:
if not re.fullmatch(r"[!#$%&'*+.^_`|~0-9A-Za-z-]+", section['header-name']):
_block(result, 'n8n.header_name', f'{prefix}.header-name')
_warn(result, 'n8n.session_ids_reset', prefix)
elif legacy == 'weknora-api':
knowledge = section.get('knowledge-base-ids')
if type(knowledge) is list and any(type(v) is str and not v.strip() for v in knowledge):
_block(result, 'invalid_value', f'{prefix}.knowledge-base-ids')
if legacy in ('coze-api', 'n8n-service-api', 'weknora-api'):
timeout = section.get('timeout', 120)
if type(timeout) in (int, float) and timeout <= 0:
_block(result, 'invalid_value', f'{prefix}.timeout')
def _validate_section(result, legacy, section):
prefix = f'ai.{legacy}'
if set(section) - _FIELDS[legacy]:
_block(result, 'unknown_field', prefix)
for field in _REQUIRED[legacy]:
if field not in section:
_block(result, 'missing_field', f'{prefix}.{field}')
# Iterate only known keys, never put an arbitrary saved key in diagnostics.
for field in sorted(_FIELDS[legacy] & section.keys()):
value = section[field]
if field in ('model', 'prompt', 'tweaks') or (legacy == 'dify-service-api' and field == 'timeout'):
continue
if value is None and (
(legacy == 'coze-api' and field in ('auto_save_history', 'auto-save-history'))
or (legacy == 'weknora-api' and field in ('agent-id', 'knowledge-base-ids'))
):
continue
if field in _BOOLEANS:
valid = type(value) is bool
elif field in _INTEGERS or (field == 'timeout' and legacy == 'deerflow-api'):
valid = type(value) is int
elif field == 'timeout':
valid = type(value) in (int, float)
elif field in _NAME_LISTS:
valid = _names(value)
elif field == 'mcp-resources':
valid = _attachments(value)
else:
valid = type(value) is str
if not valid:
_block(result, 'invalid_type', f'{prefix}.{field}')
if legacy == 'local-agent':
_validate_local(result, section)
else:
_validate_external(result, legacy, section)
if legacy == 'deerflow-api':
base = section.get('api-base')
if type(base) is str and not base.strip().startswith(('http://', 'https://')):
_block(result, 'invalid_value', f'{prefix}.api-base')
def _validate_output(result, config, legacy):
output = config.get('output', {})
if type(output) is not dict:
_block(result, 'invalid_type', 'output')
return
misc = output.get('misc', {})
if type(misc) is not dict:
_block(result, 'invalid_type', 'output.misc')
return
value = misc.get('remove-think', False)
if type(value) is not bool:
_block(result, 'invalid_type', 'output.misc.remove-think')
def _assemble(result, legacy, section, config, preferences):
selected = {**copy.deepcopy(_DEFAULTS[legacy]), **copy.deepcopy(section)}
prefix = f'ai.{legacy}'
if legacy in _IDENTITY_SOURCES:
selected['user-id-source'] = _IDENTITY_SOURCES[legacy]
if legacy == 'local-agent':
selected.pop('max-round', None)
singular = selected.pop('knowledge-base', '')
if not selected['knowledge-bases'] and singular and singular != '__none__':
selected['knowledge-bases'] = [singular]
_warn(result, 'migration.alias_repaired', f'{prefix}.knowledge-base')
model = selected['model']
selected['model'] = (
{'primary': model, 'fallbacks': [], 'reasoning': {}}
if type(model) is str
else {'fallbacks': [], 'reasoning': {}, **model}
)
preferences = preferences or {}
for field, default in (('mcp-resources', []), ('mcp-resource-agent-read-enabled', True)):
selected[field] = copy.deepcopy(section.get(field, preferences.get(field.replace('-', '_'), default)))
else:
selected.update(
{
'enable-all-tools': False,
'tools': [],
'knowledge-bases': [],
'mcp-resources': [],
'mcp-resource-agent-read-enabled': False,
}
)
if legacy in _ASSET_RUNNERS:
selected['langbot-assets-enabled'] = False
if legacy in _REMOVE_THINK_RUNNERS:
selected['remove-think'] = config.get('output', {}).get('misc', {}).get('remove-think', False)
_warn(result, 'migration.output_policy_copied', 'output.misc.remove-think')
if legacy == 'dify-service-api':
selected['timeout'] = 30
elif legacy == 'coze-api':
actual = selected.pop('auto_save_history', None)
selected['auto-save-history'] = actual if type(actual) is bool else section.get('auto-save-history')
if selected['auto-save-history'] is None:
selected['auto-save-history'] = True
elif legacy == 'dashscope-app-api':
if 'references-quote' in selected:
selected['references_quote'] = selected.pop('references-quote')
elif legacy == 'langflow-api':
for axis in ('input', 'output'):
selected[f'{axis}-type'] = selected.pop(f'{axis}_type', selected[f'{axis}-type'])
selected['tweaks'] = copy.deepcopy(_parse_tweaks(section.get('tweaks')))
elif legacy == 'weknora-api':
selected.setdefault(
'agent-id', 'builtin-quick-answer' if selected['app-type'] == 'chat' else 'builtin-smart-reasoning'
)
if selected['knowledge-base-ids'] is None:
selected['knowledge-base-ids'] = []
_warn(result, 'weknora.session_title_changed', prefix)
return selected
def plan_legacy_pipeline(config, extensions_preferences=None) -> dict:
"""Return a detached config candidate or safe, value-free diagnostics."""
result = {
'state': 'not_legacy',
'legacy_runner': None,
'target_runner_id': None,
'target_plugin': None,
'config': None,
'changed_paths': [],
'blockers': [],
'warnings': [],
}
if type(config) is not dict:
return _block(result, 'invalid_type', 'config')
if not _strict_json(config):
return _block(result, 'invalid_json_value', 'config')
ai = config.get('ai', {})
if type(ai) is not dict:
return _block(result, 'invalid_type', 'ai')
selection = ai.get('runner', {})
if type(selection) is not dict:
return _block(result, 'invalid_type', 'ai.runner')
if 'id' in selection and 'runner' in selection:
return _block(result, 'mixed_runner_selection', 'ai.runner')
if 'id' in selection:
current = selection['id']
if type(current) is not str or not re.fullmatch(r'plugin:[^/\s]+/[^/\s]+/[^/\s]+', current):
return _block(result, 'invalid_runner_id', 'ai.runner.id')
result['state'] = 'already_current'
return result
if 'runner' not in selection:
return result
legacy = selection['runner']
if type(legacy) is not str:
return _block(result, 'invalid_type', 'ai.runner.runner')
if legacy not in _TARGETS:
return result
name, version = _TARGETS[legacy]
target = f'plugin:langbot-team/{name}/default'
result.update(
legacy_runner=legacy,
target_runner_id=target,
target_plugin={'author': 'langbot-team', 'name': name, 'version': version},
)
if 'runner_config' in ai:
if type(ai['runner_config']) is not dict:
return _block(result, 'invalid_type', 'ai.runner_config')
if ai['runner_config']:
return _block(result, 'mixed_runner_config', 'ai.runner_config')
if 'expire-time' in selection:
expiry = selection['expire-time']
if type(expiry) is not int or expiry < 0:
_block(result, 'invalid_expiry', 'ai.runner.expire-time')
if set(ai) - set(_TARGETS) - {'runner', 'runner_config'}:
_block(result, 'unknown_field', 'ai')
if set(selection) - {'runner', 'expire-time'}:
_block(result, 'unknown_field', 'ai.runner')
if legacy not in ai:
return _block(result, 'missing_field', f'ai.{legacy}')
section = ai[legacy]
if type(section) is not dict:
return _block(result, 'invalid_type', f'ai.{legacy}')
_validate_section(result, legacy, section)
_validate_preferences(result, extensions_preferences, name)
_validate_output(result, config, legacy)
_warn(result, 'external.state_validation_required', 'ai.runner')
_warn(result, 'migration.history_reset', 'ai.runner')
_warn(result, 'migration.new_defaults', f'ai.{legacy}')
_warn(result, 'migration.legacy_sections_archived', 'ai')
if legacy in ('dify-service-api', 'dashscope-app-api', 'n8n-service-api'):
_warn(result, 'migration.filtered_variables', f'ai.{legacy}')
if legacy in _IDENTITY_SOURCES:
_warn(result, 'migration.identity_preserved', f'ai.{legacy}')
if result['blockers']:
return result
candidate = copy.deepcopy(config)
candidate['ai']['runner'].pop('runner')
candidate['ai']['runner']['id'] = target
selected = _assemble(result, legacy, section, config, extensions_preferences)
candidate['ai']['runner_config'] = {target: selected}
# The durable transaction stores the full source, including inactive legacy
# credentials. The active configuration must be canonical so the guarded
# editor never needs to interpret mixed legacy/current containers.
archived_paths = []
for old_runner in _TARGETS:
if old_runner in candidate['ai']:
candidate['ai'].pop(old_runner)
archived_paths.append(f'ai.{old_runner}')
result.update(
state='ready',
config=candidate,
changed_paths=['ai.runner.runner', 'ai.runner.id', 'ai.runner_config', *archived_paths],
)
return result
@@ -48,7 +48,10 @@ class MonitoringHelper:
# Try to record message
# Use JSON serialization to preserve message chain structure (including image URLs, etc.)
if hasattr(query, 'message_chain') and hasattr(query.message_chain, 'model_dump'):
message_content = json.dumps(query.message_chain.model_dump(), ensure_ascii=False)
chain_dump = query.message_chain.model_dump()
if hasattr(ap, 'storage_mgr') and hasattr(ap.storage_mgr, 'media_cache'):
chain_dump = await ap.storage_mgr.media_cache.externalize_chain_dump(chain_dump)
message_content = json.dumps(chain_dump, ensure_ascii=False)
else:
message_content = str(query)
@@ -79,6 +82,7 @@ class MonitoringHelper:
session_updated = await ap.monitoring_service.update_session_activity(
get_query_execution_context(query),
session_id,
bot_id=bot_id,
pipeline_id=pipeline_id,
pipeline_name=pipeline_name,
)
@@ -167,7 +171,10 @@ class MonitoringHelper:
if hasattr(last_resp, 'get_content_platform_message_chain'):
chain = last_resp.get_content_platform_message_chain()
if hasattr(chain, 'model_dump'):
message_content = json.dumps(chain.model_dump(), ensure_ascii=False)
chain_dump = chain.model_dump()
if hasattr(ap, 'storage_mgr') and hasattr(ap.storage_mgr, 'media_cache'):
chain_dump = await ap.storage_mgr.media_cache.externalize_chain_dump(chain_dump)
message_content = json.dumps(chain_dump, ensure_ascii=False)
else:
message_content = str(chain)
else:
+21 -5
View File
@@ -636,6 +636,16 @@ class PipelineManager:
return dataclasses.replace(context, pipeline_uuid=pipeline_uuid)
async def load_pipeline(
self,
context: ExecutionContext | RequestContext,
pipeline_entity,
*,
_binding_validated: bool = False,
):
candidate = await self.prepare_pipeline(context, pipeline_entity, _binding_validated=_binding_validated)
self.publish_pipeline(candidate)
async def prepare_pipeline(
self,
context: ExecutionContext | RequestContext,
pipeline_entity: persistence_pipeline.LegacyPipeline
@@ -657,8 +667,6 @@ class PipelineManager:
execution_context.workspace_uuid,
expected_generation=execution_context.placement_generation,
)
self._observe_execution_context(execution_context)
coerce_pipeline_config(
pipeline_entity.config,
getattr(self.ap, 'pipeline_config_meta_trigger', {'name': 'trigger', 'stages': []}),
@@ -685,17 +693,25 @@ class PipelineManager:
execution_context.workspace_uuid,
expected_generation=execution_context.placement_generation,
)
self._observe_execution_context(execution_context)
runtime_pipeline = RuntimePipeline(
return RuntimePipeline(
self.ap,
pipeline_entity,
stage_containers,
execution_context,
)
def publish_pipeline(self, runtime_pipeline: RuntimePipeline) -> None:
"""Publish a prepared candidate without yielding.
Callers preparing before a database commit must recheck the committed
source and execution binding before invoking this synchronous seam.
"""
execution_context = runtime_pipeline.execution_context
self._observe_execution_context(execution_context)
key = (
execution_context.instance_uuid,
execution_context.workspace_uuid,
pipeline_entity.uuid,
runtime_pipeline.pipeline_entity.uuid,
)
self._pipelines_by_key[key] = runtime_pipeline
self._pipeline_keys_by_scope.setdefault(key[:2], set()).add(key)
@@ -147,12 +147,14 @@ class ChatMessageHandler(handler.MessageHandler):
if result.all_content is not None:
result = result.model_copy(update={'content': result.all_content})
elif is_stream and isinstance(result, provider_message.Message):
attachments = result.attachments
result = provider_message.MessageChunk.model_validate(
{
**result.model_dump(),
'is_final': True,
}
)
result.attachments = attachments
result.resp_message_id = str(resp_message_id)
+2 -52
View File
@@ -25,50 +25,6 @@ class ResponseWrapper(stage.PipelineStage):
async def initialize(self, pipeline_config: dict):
pass
def _is_final_assistant_message(self, result) -> bool:
"""Whether *result* is the agent's final, tool-call-free answer.
Intermediate streaming chunks and tool-call rounds must NOT trigger
outbound attachment collection — only the terminal assistant message.
"""
if getattr(result, 'role', None) != 'assistant':
return False
if result.tool_calls:
return False
if isinstance(result, provider_message.MessageChunk):
return bool(result.is_final)
return True
async def _append_outbound_attachments(
self,
query: pipeline_query.Query,
message_chain: platform_message.MessageChain,
) -> None:
"""Collect sandbox outbox files and append them to *message_chain*.
Runs at most once per query (guarded by a query variable) and never
raises into the pipeline — attachment delivery is best-effort.
"""
if query.variables.get('_sandbox_outbound_collected'):
return
box_service = getattr(self.ap, 'box_service', None)
if box_service is None or not getattr(box_service, 'available', False):
return
query.variables['_sandbox_outbound_collected'] = True
try:
attachments = await box_service.collect_outbound_attachments(query)
except Exception as e:
self.ap.logger.warning(f'Outbound attachment collection failed: {e}')
return
for att in attachments:
att_type = att.get('type')
if att_type == 'Image':
message_chain.append(platform_message.Image(base64=att['base64']))
elif att_type == 'Voice':
message_chain.append(platform_message.Voice(base64=att['base64']))
else:
message_chain.append(platform_message.File(name=att.get('name', 'file'), base64=att['base64']))
async def process(
self,
query: pipeline_query.Query,
@@ -105,7 +61,7 @@ class ResponseWrapper(stage.PipelineStage):
reply_text = ''
if result.content: # 有内容
if result.content or result.attachments: # 有内容
reply_text = str(result.get_content_platform_message_chain())
# ============= 触发插件事件 ===============
@@ -140,11 +96,6 @@ class ResponseWrapper(stage.PipelineStage):
reply_chain = result.get_content_platform_message_chain()
is_plugin_reply = False
# Attach files the agent produced in the sandbox
# outbox, but only on the terminal assistant message.
if self._is_final_assistant_message(result):
await self._append_outbound_attachments(query, reply_chain)
query.resp_message_chain.append(reply_chain)
if is_plugin_reply:
plugin_diagnostics.record_last_plugin_response_source(
@@ -162,9 +113,8 @@ class ResponseWrapper(stage.PipelineStage):
isinstance(result, provider_message.MessageChunk) and result.is_final and not result.tool_calls
):
# Final streaming chunk with no text content but
# possibly carrying sandbox outbox attachments.
# no implicit file collection.
reply_chain = platform_message.MessageChain([])
await self._append_outbound_attachments(query, reply_chain)
query.resp_message_chain.append(reply_chain)
yield entities.StageProcessResult(
result_type=entities.ResultType.CONTINUE,
+4 -1
View File
@@ -1144,7 +1144,10 @@ class RuntimeBot:
"""Record a discarded message in the monitoring system."""
try:
if hasattr(message_chain, 'model_dump'):
message_content = json.dumps(message_chain.model_dump(), ensure_ascii=False)
chain_dump = message_chain.model_dump()
if hasattr(self.ap, 'storage_mgr') and hasattr(self.ap.storage_mgr, 'media_cache'):
chain_dump = await self.ap.storage_mgr.media_cache.externalize_chain_dump(chain_dump)
message_content = json.dumps(chain_dump, ensure_ascii=False)
else:
message_content = str(message_chain)
+67
View File
@@ -0,0 +1,67 @@
"""Authenticated plugin Box resources and invocation file operations."""
from langbot_plugin.entities.io.actions.enums import PluginToRuntimeAction as Action
from langbot_plugin.runtime.io.handler import ActionResponse
from ..box.runner import RunnerBoxService
from .agent_run_support import _validate_agent_run_session
def register(h):
async def dispatch(action, data):
from .handler import _resolve_action_query
action_context, _ = await h._require_plugin_action_context()
context = h._execution_context(action_context)
query = None
run_id = data.get('run_id')
context_actions = {Action.BIND_BOX, Action.IMPORT_BOX_ATTACHMENTS, Action.EXPORT_BOX_FILES}
if action in context_actions and not run_id:
return ActionResponse.error('run_id is required for Box context operations')
if run_id:
session, error = await _validate_agent_run_session(
run_id,
data.get('caller_plugin_identity'),
h.ap,
'Box API',
api_capability='box',
)
if error:
return error
query = _resolve_action_query(data, session, h.ap, action_context)
if query is None:
return ActionResponse.error('The current execution context has expired')
service = RunnerBoxService(h.ap.box_service)
if action == Action.GET_BOX_STATUS:
return ActionResponse.success(await service.status(context))
if action == Action.LIST_BOXES:
return ActionResponse.success(
{'items': [service.public_session(s) for s in await service.sessions(context)]}
)
if action == Action.ACQUIRE_BOX:
request = {k: v for k, v in data.items() if k not in {'run_id', 'caller_plugin_identity'}}
return ActionResponse.success(await service.acquire(context, request, query))
if action == Action.BIND_BOX:
return ActionResponse.success(await service.bind(context, query, run_id, data['box_id']))
if action == Action.IMPORT_BOX_ATTACHMENTS:
return ActionResponse.success(await service.import_attachments(query, data.get('attachment_ids')))
if action == Action.EXPORT_BOX_FILES:
return ActionResponse.success(await service.export_files(query))
raise ValueError('Unsupported Box action')
def register_action(action):
@h.action(action)
async def invoke(data):
try:
return await dispatch(action, data)
except Exception as exc:
return ActionResponse.error(f'{type(exc).__name__}: {exc}')
for action in (
Action.GET_BOX_STATUS,
Action.LIST_BOXES,
Action.ACQUIRE_BOX,
Action.BIND_BOX,
Action.IMPORT_BOX_ATTACHMENTS,
Action.EXPORT_BOX_FILES,
):
register_action(action)
+45 -19
View File
@@ -23,6 +23,11 @@ from langbot_plugin.api.entities.builtin.pipeline.query import provider_session
from ..core import app
from . import handler
from .errors import (
PluginRuntimeNotConnectedError,
PluginInstallationFailedError,
MarketplacePluginVersionNotFoundError,
)
from .archive import inspect_plugin_archive_metadata
from .github import (
validate_github_plugin_install_info,
@@ -89,8 +94,10 @@ async def _read_httpx_response_limited(
response: httpx.Response,
*,
max_bytes: int,
task_context: taskmgr.TaskContext | None = None,
) -> bytes:
content_length = response.headers.get('content-length')
declared_size = None
if content_length is not None:
try:
declared_size = int(content_length)
@@ -99,9 +106,16 @@ async def _read_httpx_response_limited(
if declared_size is not None and declared_size > max_bytes:
raise ValueError(f'Remote response exceeds the {max_bytes}-byte limit')
started = time.monotonic()
if task_context is not None:
task_context.metadata.update(download_current=0, download_total=max(declared_size or 0, 0))
body = bytearray()
async for chunk in response.aiter_bytes(chunk_size=64 * 1024):
body.extend(chunk)
if task_context is not None:
task_context.metadata.update(
download_current=len(body), download_speed=len(body) / max(time.monotonic() - started, 0.001)
)
if len(body) > max_bytes:
raise ValueError(f'Remote response exceeds the {max_bytes}-byte limit')
return bytes(body)
@@ -113,14 +127,25 @@ async def _marketplace_get(
*,
max_bytes: int,
allow_not_found: bool = False,
task_context: taskmgr.TaskContext | None = None,
) -> tuple[int, bytes]:
async with client.stream('GET', url) as response:
if allow_not_found and response.status_code == 404:
return response.status_code, b''
if response.is_error:
body = await _read_httpx_response_limited(response, max_bytes=min(max_bytes, 64 * 1024))
try:
payload = json.loads(body)
except (ValueError, UnicodeDecodeError):
payload = {}
# Space currently returns HTTP 500 for a missing plugin release.
if isinstance(payload, dict) and str(payload.get('msg', '')).startswith('plugin version not found:'):
raise MarketplacePluginVersionNotFoundError('The requested plugin version is not available')
response.raise_for_status()
return response.status_code, await _read_httpx_response_limited(
response,
max_bytes=max_bytes,
task_context=task_context,
)
@@ -134,25 +159,6 @@ def _decode_json_object(body: bytes, *, subject: str) -> dict[str, Any]:
return payload
class PluginRuntimeNotConnectedError(RuntimeError):
"""Raised when plugin runtime operations are requested before connection."""
class PluginInstallationFailedError(RuntimeError):
"""Stable Runtime desired-state failure for one plugin installation."""
def __init__(
self,
installation_uuid: str,
error_code: str,
message: str,
) -> None:
self.installation_uuid = installation_uuid
self.error_code = error_code
self.runtime_message = message
super().__init__(f'Plugin installation {installation_uuid} failed [{error_code}]: {message}')
class PluginRuntimeConnector(ManagedRuntimeConnector):
"""Plugin runtime connector"""
@@ -1631,6 +1637,7 @@ class PluginRuntimeConnector(ManagedRuntimeConnector):
plugin_author: str,
plugin_name: str,
task_context: taskmgr.TaskContext | None,
version: str | None = None,
) -> tuple[bytes | None, str | None]:
"""Return a plugin package, or install an MCP/skill and return none."""
@@ -1640,6 +1647,23 @@ class PluginRuntimeConnector(ManagedRuntimeConnector):
timeout=15,
event_hooks=httpclient.httpx_response_limit_hooks(_MARKETPLACE_PLUGIN_DOWNLOAD_MAX_BYTES),
) as client:
if version is not None:
if (
not isinstance(version, str)
or not version
or any(
c not in 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789._+-' for c in version
)
):
raise ValueError('Invalid plugin version')
_status, package = await _marketplace_get(
client,
f'{space_url}/api/v1/marketplace/plugins/download/{plugin_author}/{plugin_name}/{version}',
max_bytes=_MARKETPLACE_PLUGIN_DOWNLOAD_MAX_BYTES,
task_context=task_context,
)
return package, version
mcp_status, mcp_body = await _marketplace_get(
client,
f'{space_url}/api/v1/marketplace/mcps/{plugin_author}/{plugin_name}',
@@ -1726,11 +1750,13 @@ class PluginRuntimeConnector(ManagedRuntimeConnector):
if task_context is not None:
task_context.set_current_action('downloading plugin package')
task_context.metadata['progress_percent'] = 15
version_options = {'version': install_info['plugin_version']} if install_info.get('plugin_version') else {}
file_bytes, version = await self._download_marketplace_package(
execution_context,
plugin_author,
plugin_name,
task_context,
**version_options,
)
if file_bytes is None:
return
+24
View File
@@ -0,0 +1,24 @@
"""Shared plugin operation errors without runtime or application imports."""
class PluginRuntimeNotConnectedError(RuntimeError):
"""Raised when plugin runtime operations are requested before connection."""
class PluginInstallationFailedError(RuntimeError):
"""Stable Runtime desired-state failure for one plugin installation."""
def __init__(
self,
installation_uuid: str,
error_code: str,
message: str,
) -> None:
self.installation_uuid = installation_uuid
self.error_code = error_code
self.runtime_message = message
super().__init__(f'Plugin installation {installation_uuid} failed [{error_code}]: {message}')
class MarketplacePluginVersionNotFoundError(ValueError):
"""The requested release is not available from the marketplace."""
+56 -5
View File
@@ -52,6 +52,7 @@ from ..entity.persistence import model as persistence_model
from ..core import app
from ..utils import constants
from ..agent.runner.session_registry import get_session_registry
from ..provider.modelmgr.reasoning import model_with_reasoning_level
from ..agent.runner.config_resolver import RunnerConfigResolver
from ..agent.runner import config_schema
from ..agent.runner.platform_tools import execute_platform_tool, get_platform_tool_detail, resolve_platform_api_call
@@ -204,6 +205,7 @@ async def _validate_run_authorization(
ap: app.Application,
caller_plugin_identity: str | None = None,
operation: str | None = None,
workspace_uuid: str | None = None,
) -> Union[tuple[None, handler.ActionResponse], tuple[Any, None]]:
"""Validate run_id authorization for a resource access.
@@ -250,6 +252,9 @@ async def _validate_run_authorization(
message=f'Plugin identity mismatch: caller {caller_plugin_identity} is not authorized for run_id {run_id}',
)
if workspace_uuid is not None and session['authorization'].get('workspace_id') not in (None, workspace_uuid):
return None, handler.ActionResponse.error(message='Run session belongs to another Workspace')
if not session_registry.is_resource_allowed(session, resource_type, resource_id, operation):
ap.logger.warning(
f'{resource_type.upper()}: {resource_id} operation {operation or "*"} not allowed for run_id {run_id}'
@@ -1124,6 +1129,7 @@ class RuntimeConnectionHandler(handler.Handler):
return handler.ActionResponse.success(
data={
'version': constants.semantic_version,
'api_features': ['llm.reasoning_level'],
},
)
@@ -1183,7 +1189,18 @@ class RuntimeConnectionHandler(handler.Handler):
)
if error:
return error
output_lock = None
output_lock_acquired = False
try:
if data.get('file_ids') is not None:
from ..box.runner import exported_message, binding_for
query = _resolve_action_query(data, session, self.ap, action_context)
output_lock = binding_for(query).lock
await output_lock.acquire()
output_lock_acquired = True
files = exported_message(query, data['file_ids'])
data = {**data, 'params': {**(data.get('params') or {}), 'message': files.model_dump(mode='json')}}
tool_name, parameters, message = resolve_platform_api_call(
session, data.get('bot_uuid'), data['action'], data.get('params') or {}, data.get('context_tool')
)
@@ -1203,9 +1220,14 @@ class RuntimeConnectionHandler(handler.Handler):
parameters,
message_chain=message,
)
if data.get('file_ids') is not None:
exported_message(query, data['file_ids'], consume=True)
return handler.ActionResponse.success(data={'result': _serialize_plugin_api_result(result)})
except (ValueError, KeyError, TypeError) as exc:
return handler.ActionResponse.error(message=str(exc))
finally:
if output_lock_acquired:
output_lock.release()
@self.action(PluginToRuntimeAction.SEND_MESSAGE)
async def send_message(data: dict[str, Any]) -> handler.ActionResponse:
@@ -1341,8 +1363,14 @@ class RuntimeConnectionHandler(handler.Handler):
caller_plugin_identity = data.get('caller_plugin_identity')
if run_id:
_session, error = await _validate_run_authorization(
run_id, 'model', llm_model_uuid, self.ap, caller_plugin_identity, operation='count_tokens'
_, error = await _validate_run_authorization(
run_id,
'model',
llm_model_uuid,
self.ap,
caller_plugin_identity,
operation='count_tokens',
workspace_uuid=action_context.workspace_uuid,
)
if error:
return error
@@ -1366,6 +1394,9 @@ class RuntimeConnectionHandler(handler.Handler):
message=f'LLM model with llm_model_uuid {llm_model_uuid} not found',
)
if getattr(llm_model.model_entity, 'workspace_uuid', None) not in (None, action_context.workspace_uuid):
return handler.ActionResponse.error(message='LLM model belongs to another Workspace')
llm_model = model_with_reasoning_level(llm_model, data.get('reasoning_level'))
messages_obj = [provider_message.Message.model_validate(message) for message in messages]
async def _placeholder_func(**kwargs):
@@ -1407,7 +1438,13 @@ class RuntimeConnectionHandler(handler.Handler):
# Permission validation for Runner calls
if run_id:
session, error = await _validate_run_authorization(
run_id, 'model', llm_model_uuid, self.ap, caller_plugin_identity, operation='invoke'
run_id,
'model',
llm_model_uuid,
self.ap,
caller_plugin_identity,
operation='invoke',
workspace_uuid=action_context.workspace_uuid,
)
if error:
return error
@@ -1441,6 +1478,7 @@ class RuntimeConnectionHandler(handler.Handler):
message=f'LLM model with llm_model_uuid {llm_model_uuid} not found',
)
llm_model = model_with_reasoning_level(llm_model, data.get('reasoning_level'))
messages_obj = [provider_message.Message.model_validate(message) for message in messages]
# The func field is excluded during model_dump() in plugin side (marked as exclude=True),
@@ -1500,7 +1538,13 @@ class RuntimeConnectionHandler(handler.Handler):
# Permission validation for Runner calls
if run_id:
session, error = await _validate_run_authorization(
run_id, 'model', llm_model_uuid, self.ap, caller_plugin_identity, operation='stream'
run_id,
'model',
llm_model_uuid,
self.ap,
caller_plugin_identity,
operation='stream',
workspace_uuid=action_context.workspace_uuid,
)
if error:
yield error
@@ -1528,6 +1572,10 @@ class RuntimeConnectionHandler(handler.Handler):
)
return
if getattr(llm_model.model_entity, 'workspace_uuid', None) not in (None, action_context.workspace_uuid):
yield handler.ActionResponse.error(message='LLM model belongs to another Workspace')
return
llm_model = model_with_reasoning_level(llm_model, data.get('reasoning_level'))
messages_obj = [provider_message.Message.model_validate(message) for message in messages]
# The func field is excluded during model_dump() in plugin side
@@ -2601,6 +2649,9 @@ class RuntimeConnectionHandler(handler.Handler):
agent_pull_actions.register(self)
runner_actions.register(self)
from . import box_actions
box_actions.register(self)
agent_state_actions.register(self)
@self.action(CommonAction.PING)
@@ -2675,7 +2726,7 @@ class RuntimeConnectionHandler(handler.Handler):
return None
def require_outbound_installation_context(self) -> InstallationBinding:
binding = self._outbound_installation_context.get()
binding = self._outbound_installation_context.get(None)
if not isinstance(binding, InstallationBinding):
raise ValueError('Host plugin action requires an InstallationBinding scope')
return binding
@@ -0,0 +1,420 @@
"""ChatGPT device auth with server-only credentials and cross-process refresh leases.
Network I/O never holds a DB transaction. A persisted CAS lease serializes refresh
and poll; cancel fences device exchanges but waits for existing-token refreshes.
"""
from __future__ import annotations
import asyncio
import base64
import json
import math
import secrets
import time
from contextlib import asynccontextmanager
from datetime import datetime, timezone
import httpx
import sqlalchemy as sa
from ...entity.persistence.model import CodexCredential, ModelProvider
from ...api.http.context import PrincipalType, RequestContext
from ...api.http.authz import Permission, has_permission
from ...api.http.service.tenant import require_workspace_uuid
from ...workspace.errors import WorkspaceNotFoundError
REQUESTER = 'openai-codex'
BASE_URL = 'https://chatgpt.com/backend-api/codex'
ISSUER = 'https://auth.openai.com'
CLIENT_ID = 'app_EMoamEEZ73f0CkXaXp7hrann'
LOGIN_REQUIRED = 'ChatGPT sign-in required. Open this provider and sign in again.'
LEASE_SECONDS = 90
def validate_config(data: dict) -> None:
if data.get('requester') != REQUESTER:
return
if data.get('base_url') not in (None, '', BASE_URL):
raise ValueError('Codex uses the fixed ChatGPT endpoint; custom base URLs are not supported')
if data.get('api_keys') not in (None, [], ''):
raise ValueError('Codex uses ChatGPT sign-in, not API keys')
data['base_url'] = BASE_URL
data['api_keys'] = []
def _claims(token: str) -> dict:
"""Read routing metadata, NOT trusted LangBot identity, from issuer tokens."""
try:
part = token.split('.')[1]
value = json.loads(base64.urlsafe_b64decode(part + '=' * (-len(part) % 4)))
return value if isinstance(value, dict) else {}
except (ValueError, IndexError, TypeError):
return {}
def _tokens(data: dict, previous: dict | None = None) -> dict:
previous = previous or {}
access = data.get('access_token')
refresh = data.get('refresh_token') or previous.get('refresh_token')
account = None
for token in (access, data.get('id_token')):
namespace = _claims(token or '').get('https://api.openai.com/auth', {})
if isinstance(namespace, dict) and isinstance(namespace.get('chatgpt_account_id'), str):
account = namespace['chatgpt_account_id']
break
account = account or previous.get('account_id')
try:
expires_at = (
time.time() + float(data['expires_in'])
if data.get('expires_in') is not None
else float(_claims(access or '').get('exp', 0))
)
except (TypeError, ValueError):
expires_at = 0
if (
not all(isinstance(v, str) and v for v in (access, refresh, account))
or not math.isfinite(expires_at)
or expires_at <= time.time()
):
raise ValueError('ChatGPT returned an incomplete authorization. Please sign in again.')
return {
'access_token': access,
'refresh_token': refresh,
'account_id': account,
'expires_at': expires_at,
'connection_id': previous.get('connection_id') or secrets.token_urlsafe(24),
}
class CodexAuth:
def __init__(self, ap):
self.ap = ap
def _where(self, workspace: str, provider: str):
return (CodexCredential.workspace_uuid == workspace, CodexCredential.provider_uuid == provider)
async def _execute(self, statement):
# SQLAlchemy/driver/serialization errors may embed the entire secret payload.
try:
return await self.ap.persistence_mgr.execute_async(statement)
except Exception:
raise ValueError('ChatGPT credential storage failed. Please retry.') from None
async def _read(self, workspace: str, provider: str) -> dict | None:
result = await self._execute(sa.select(CodexCredential).where(*self._where(workspace, provider)))
try:
row = result.first()
return dict(row._mapping) if row is not None else None
except Exception:
raise ValueError('ChatGPT credential storage failed. Please retry.') from None
async def _provider(self, context, provider: str, *, user: bool = False) -> str:
workspace = require_workspace_uuid(context)
if user and (
not isinstance(context, RequestContext)
or context.principal.principal_type != PrincipalType.ACCOUNT
or not context.account_uuid
or not has_permission(context, Permission.PROVIDER_SECRET_MANAGE)
):
raise ValueError('ChatGPT authorization requires an authorized workspace user')
result = await self._execute(
sa.select(ModelProvider.requester).where(
ModelProvider.workspace_uuid == workspace, ModelProvider.uuid == provider
)
)
kind = result.scalar()
if kind is None:
raise WorkspaceNotFoundError('Provider not found')
if kind != REQUESTER:
raise ValueError('This provider does not use ChatGPT sign-in')
return workspace
@asynccontextmanager
async def _lease(self, workspace: str, provider: str, *, refresh: bool = False):
owner = ('refresh:' if refresh else 'device:') + secrets.token_urlsafe(32)
deadline = time.monotonic() + 65
while True:
now = time.time()
result = await self._execute(
sa.update(CodexCredential)
.where(
*self._where(workspace, provider),
sa.or_(CodexCredential.lease_owner.is_(None), CodexCredential.lease_until < now),
)
.values(lease_owner=owner, lease_until=now + LEASE_SECONDS)
)
if result.rowcount == 1:
break
if await self._read(workspace, provider) is None:
raise ValueError(LOGIN_REQUIRED)
if time.monotonic() >= deadline:
raise ValueError('ChatGPT authorization is busy. Please retry shortly.')
await asyncio.sleep(0.1)
try:
yield owner
finally:
await self._execute(
sa.update(CodexCredential)
.where(*self._where(workspace, provider), CodexCredential.lease_owner == owner)
.values(lease_owner=None, lease_until=0)
)
async def _save(self, workspace: str, provider: str, owner: str, payload: dict) -> None:
result = await self._execute(
sa.update(CodexCredential)
.where(
*self._where(workspace, provider),
CodexCredential.lease_owner == owner,
CodexCredential.lease_until > time.time(),
)
.values(payload=payload, version=CodexCredential.version + 1)
)
if result.rowcount != 1:
raise ValueError('ChatGPT authorization was cancelled or replaced. Please retry.')
async def _post(self, path: str, *, data=None, json_body=None) -> httpx.Response:
try:
async with httpx.AsyncClient(timeout=20, follow_redirects=False) as client:
return await asyncio.wait_for(
client.post(
ISSUER + path,
data=data,
json=json_body,
headers={'Accept': 'application/json', 'User-Agent': 'LangBot'},
),
25,
)
except (httpx.HTTPError, TimeoutError):
raise ValueError('ChatGPT authorization network error. Please retry.') from None
@staticmethod
def _json(response: httpx.Response) -> dict:
try:
value = response.json()
if not isinstance(value, dict):
raise ValueError
return value
except ValueError:
raise ValueError('ChatGPT returned an invalid authorization response') from None
async def status(self, context, provider: str) -> dict:
workspace = await self._provider(context, provider, user=True)
row = await self._read(workspace, provider)
payload = row['payload'] if row else {}
tokens = payload.get('tokens')
connected = bool(tokens and not payload.get('invalid'))
return {
'status': 'connected' if connected else 'expired' if payload.get('invalid') else 'disconnected',
'connected': connected,
'expires_at': tokens.get('expires_at') if tokens else None,
}
async def start(self, context, provider: str) -> dict:
workspace = await self._provider(context, provider, user=True)
async with self._lease(workspace, provider) as owner:
response = await self._post('/api/accounts/deviceauth/usercode', json_body={'client_id': CLIENT_ID})
if response.status_code != 200:
raise ValueError('Unable to start ChatGPT device login. Enable device code login in ChatGPT settings.')
data = self._json(response)
try:
code = data.get('user_code') or data['usercode']
device = data['device_auth_id']
interval = max(5, min(60, int(data.get('interval') or 5)))
if not isinstance(code, str) or not isinstance(device, str) or not code or not device:
raise ValueError
except (KeyError, ValueError, TypeError):
raise ValueError('ChatGPT returned an invalid device code') from None
now = time.time()
try:
expiry = data.get('expires_at')
if expiry is None:
expiry = now + float(data.get('expires_in', 900))
try:
expires_at = float(expiry)
except ValueError:
parsed = datetime.fromisoformat(expiry.replace('Z', '+00:00'))
if parsed.tzinfo is None:
parsed = parsed.replace(tzinfo=timezone.utc)
expires_at = parsed.timestamp()
if not math.isfinite(expires_at) or expires_at <= now:
raise ValueError
expires_at = min(now + 900, expires_at)
except (ValueError, TypeError):
raise ValueError('ChatGPT returned an invalid device code expiry') from None
pending = {
'authorization_id': secrets.token_urlsafe(32),
'user_code': code,
'device_auth_id': device,
'account_uuid': context.account_uuid,
'interval': interval,
'expires_at': expires_at,
'next_poll_at': now + interval,
}
row = await self._read(workspace, provider)
payload = dict(row['payload'])
payload['pending'] = pending
await self._save(workspace, provider, owner, payload)
return {k: pending[k] for k in ('authorization_id', 'user_code', 'interval', 'expires_at')} | {
'verification_uri': ISSUER + '/codex/device'
}
@staticmethod
def _attempt(payload: dict, context, authorization_id: str) -> dict | None:
pending = payload.get('pending')
if not pending or pending.get('authorization_id') != authorization_id:
return None
if pending.get('account_uuid') != context.account_uuid:
raise WorkspaceNotFoundError('Authorization not found')
return pending
async def poll(self, context, provider: str, authorization_id: str) -> dict:
workspace = await self._provider(context, provider, user=True)
if not isinstance(authorization_id, str) or not authorization_id:
raise ValueError('authorization_id is required')
async with self._lease(workspace, provider) as owner:
row = await self._read(workspace, provider)
payload = dict(row['payload'])
pending = self._attempt(payload, context, authorization_id)
if pending is None:
completed = payload.get('completed', {})
if (
completed.get('authorization_id') == authorization_id
and completed.get('account_uuid') == context.account_uuid
):
return {'status': 'connected'}
return {'status': 'expired'}
now = time.time()
if pending['expires_at'] <= now or pending.get('consumed'):
payload.pop('pending', None)
await self._save(workspace, provider, owner, payload)
return {'status': 'expired'}
if pending['next_poll_at'] > now:
return {'status': 'pending', 'interval': pending['interval']}
pending['next_poll_at'] = now + pending['interval']
await self._save(workspace, provider, owner, payload)
response = await self._post(
'/api/accounts/deviceauth/token',
json_body={'device_auth_id': pending['device_auth_id'], 'user_code': pending['user_code']},
)
if response.status_code in (403, 404, 429):
if response.status_code == 429:
pending['interval'] = min(60, pending['interval'] + 5)
pending['next_poll_at'] = time.time() + pending['interval']
await self._save(workspace, provider, owner, payload)
return {'status': 'pending', 'interval': pending['interval']}
if response.status_code != 200:
payload.pop('pending', None)
await self._save(workspace, provider, owner, payload)
raise ValueError('ChatGPT device authorization failed. Please start again.')
data = self._json(response)
if not data.get('authorization_code') or not data.get('code_verifier'):
payload.pop('pending', None)
await self._save(workspace, provider, owner, payload)
raise ValueError('ChatGPT returned an incomplete device authorization')
# Keep an attempt tombstone so cancel can preempt exchange, but never replay a code.
pending['consumed'] = True
await self._save(workspace, provider, owner, payload)
response = await self._post(
'/oauth/token',
data={
'grant_type': 'authorization_code',
'client_id': CLIENT_ID,
'code': data['authorization_code'],
'code_verifier': data['code_verifier'],
'redirect_uri': ISSUER + '/deviceauth/callback',
},
)
if response.status_code != 200:
raise ValueError('ChatGPT token exchange failed. Please start sign-in again.')
tokens = _tokens(self._json(response))
await self._save(
workspace,
provider,
owner,
{
'tokens': tokens,
'completed': {'authorization_id': authorization_id, 'account_uuid': context.account_uuid},
},
)
return {'status': 'connected'}
async def disconnect(self, context, provider: str) -> None:
workspace = await self._provider(context, provider, user=True)
await self._execute(
sa.update(CodexCredential)
.where(*self._where(workspace, provider))
.values(payload={}, lease_owner=None, lease_until=0, version=CodexCredential.version + 1)
)
async def cancel(self, context, provider: str, authorization_id: str) -> None:
workspace = await self._provider(context, provider, user=True)
deadline = time.monotonic() + 65
while time.monotonic() < deadline:
row = await self._read(workspace, provider)
if row is None:
return
old = row['payload']
if self._attempt(old, context, authorization_id) is None:
return
lease_owner = row['lease_owner']
if lease_owner and lease_owner.startswith('refresh:') and row['lease_until'] > time.time():
# A rotated refresh token must be committed before removing the attempt.
await asyncio.sleep(0.1)
continue
payload = dict(old)
payload.pop('pending', None)
result = await self._execute(
sa.update(CodexCredential)
.where(
*self._where(workspace, provider),
CodexCredential.version == row['version'],
# Lease acquisition does not change version; fence that race too.
CodexCredential.lease_owner == lease_owner,
)
.values(payload=payload, lease_owner=None, lease_until=0, version=CodexCredential.version + 1)
)
if result.rowcount == 1:
return
raise ValueError('Authorization changed concurrently. Please retry cancellation.')
async def access(self, context, provider: str, *, rejected_token: str | None = None) -> dict:
workspace = await self._provider(context, provider)
row = await self._read(workspace, provider)
payload = row['payload'] if row else {}
tokens = payload.get('tokens')
if not tokens or payload.get('invalid'):
raise ValueError(LOGIN_REQUIRED)
if tokens['expires_at'] > time.time() + 120 and tokens['access_token'] != rejected_token:
return tokens
async with self._lease(workspace, provider, refresh=True) as owner:
row = await self._read(workspace, provider)
payload = dict(row['payload'])
tokens = payload.get('tokens')
if not tokens or payload.get('invalid'):
raise ValueError(LOGIN_REQUIRED)
if tokens['expires_at'] > time.time() + 120 and tokens['access_token'] != rejected_token:
return tokens
response = await self._post(
'/oauth/token',
data={'grant_type': 'refresh_token', 'client_id': CLIENT_ID, 'refresh_token': tokens['refresh_token']},
)
error = self._json(response).get('error') if response.status_code in (400, 401, 403) else None
error_code = error.get('code') if isinstance(error, dict) else error
if error_code in (
'invalid_grant',
'refresh_token_reused',
'refresh_token_expired',
'refresh_token_revoked',
):
payload['invalid'] = True
payload.pop('tokens', None)
payload.pop('completed', None)
await self._save(workspace, provider, owner, payload)
raise ValueError(LOGIN_REQUIRED)
if response.status_code != 200:
raise ValueError('ChatGPT token refresh temporarily failed. Please retry.')
refreshed = _tokens(self._json(response), tokens)
payload['tokens'] = refreshed
await self._save(workspace, provider, owner, payload)
return refreshed
@@ -0,0 +1,10 @@
"""Explicitly safe Codex failures; never construct messages from upstream bodies."""
class CodexProviderError(ValueError):
"""A known provider failure safe to expose at the HTTP boundary."""
def __init__(self, message: str, status_code: int = 502, error_code: str = 'codex_upstream_failure'):
super().__init__(message)
self.status_code = status_code
self.error_code = error_code
+31 -5
View File
@@ -18,7 +18,7 @@ from ...discover import engine
from ...entity.errors import provider as provider_errors
from ...entity.persistence import model as persistence_model
from ...workspace.entities import WorkspaceExecutionBinding
from ...workspace.errors import WorkspaceError, WorkspaceInvariantError
from ...workspace.errors import WorkspaceError, WorkspaceInvariantError, WorkspaceNotFoundError
from . import requester, token
@@ -638,10 +638,32 @@ class ModelManager:
) -> requester.RuntimeLLMModel:
execution_context = await self.resolve_execution_context(context)
provider_info = {**model_info.get('provider', {}), 'workspace_uuid': execution_context.workspace_uuid}
runtime_provider = await self._build_provider(
execution_context,
persistence_model.ModelProvider(**provider_info),
)
provider_uuid = model_info.get('provider_uuid') or provider_info.get('uuid')
inline_codex = provider_info.get('requester') == 'openai-codex'
provider_entity = persistence_model.ModelProvider(**provider_info)
if provider_uuid:
if provider_info.get('uuid') and provider_info['uuid'] != provider_uuid:
raise ValueError('Conflicting provider identities')
result = await self.ap.persistence_mgr.execute_async(
sqlalchemy.select(persistence_model.ModelProvider).where(
persistence_model.ModelProvider.workspace_uuid == execution_context.workspace_uuid,
persistence_model.ModelProvider.uuid == provider_uuid,
)
)
saved_provider = result.first()
if saved_provider is None:
if inline_codex or model_info.get('provider_uuid'):
raise WorkspaceNotFoundError('Provider not found')
else:
saved_provider = self._coerce_provider(saved_provider, execution_context)
if saved_provider.requester == 'openai-codex':
# OAuth identity and transport configuration are server-owned.
provider_entity = saved_provider
elif inline_codex:
raise ValueError('This provider does not use ChatGPT sign-in')
elif inline_codex:
raise WorkspaceNotFoundError('Provider not found')
runtime_provider = await self._build_provider(execution_context, provider_entity)
model_entity = persistence_model.LLMModel(
workspace_uuid=execution_context.workspace_uuid,
uuid=model_info.get('uuid', ''),
@@ -723,6 +745,10 @@ class ModelManager:
'requester_name': provider_entity.requester,
}
if provider_entity.requester == 'openai-codex':
config['provider_uuid'] = provider_entity.uuid
config['workspace_uuid'] = context.workspace_uuid
if litellm_provider:
from .requesters import litellmchat
@@ -1,6 +1,10 @@
from __future__ import annotations
import typing
import copy
if typing.TYPE_CHECKING:
from .requester import RuntimeLLMModel
ReasoningLevel = typing.Literal[
@@ -123,3 +127,30 @@ def default_reasoning_capabilities(
'levels': ['provider_default'],
'source': source,
}
def model_with_reasoning_level(
model: RuntimeLLMModel,
level: ReasoningLevel | None,
) -> RuntimeLLMModel:
"""Clone only the runtime wrapper, after the caller has authorized the model.
Do not mutate shared model entities or providers. Requesters retain ownership
of ability/capability validation and provider-specific argument translation.
"""
if level is None:
return model
abilities = set(model.model_entity.abilities or [])
# Managed Space models are read-only. Their detected capability is also what
# the model API and UI expose, even when the catalog omits the ability flag.
# Custom providers still require the user's explicit ability setting.
if (
'reasoning' not in abilities
and model.provider.provider_entity.requester == 'space-chat-completions'
and model.provider.requester.get_reasoning_capabilities(model).get('supported') is True
):
abilities.add('reasoning')
config = validate_reasoning_config({'level': level}, abilities, model.model_entity.extra_args)
scoped_model = copy.copy(model)
scoped_model.reasoning_config_override = copy.deepcopy(config)
return scoped_model
@@ -0,0 +1,426 @@
"""Native ChatGPT Codex Responses/SSE requester (never Chat Completions)."""
from __future__ import annotations
import asyncio
import json
import secrets
import time
from collections import OrderedDict
import httpx
import langbot
import langbot_plugin.api.entities.builtin.provider.message as pm
from .. import requester, reasoning
from ..codex_auth import BASE_URL, CodexAuth, LOGIN_REQUIRED
from ..codex_errors import CodexProviderError
async def sse_events(response):
"""Decode SSE records, including CRLF, comments, and multiline data."""
data = []
size = 0
async for line in response.aiter_lines():
if not line:
if data:
text = '\n'.join(data)
if text == '[DONE]':
return
try:
event = json.loads(text)
if not isinstance(event, dict):
raise ValueError
except ValueError:
raise ValueError('Codex returned an invalid stream event') from None
yield event
data, size = [], 0
elif line.startswith('data:'):
value = line[5:]
if value.startswith(' '):
value = value[1:]
size += len(value)
if size > 4 * 1024 * 1024:
raise ValueError('Codex stream event exceeds the size limit')
data.append(value)
# SSE requires the blank separator; unterminated records cannot prove completion.
def _content(message):
content = message.content
if isinstance(content, str):
return [{'type': 'output_text' if message.role == 'assistant' else 'input_text', 'text': content}]
result = []
for part in content or []:
if part.type == 'text':
result.append(
{'type': 'output_text' if message.role == 'assistant' else 'input_text', 'text': part.text or ''}
)
elif part.type == 'image_url' and part.image_url is not None:
result.append({'type': 'input_image', 'image_url': part.image_url.url})
elif part.type == 'image_base64' and part.image_base64:
value = part.image_base64
result.append(
{
'type': 'input_image',
'image_url': value if value.startswith('data:') else 'data:image/png;base64,' + value,
}
)
else:
raise ValueError('Codex supports text and images only; this message contains unsupported content')
return result
def _tool(item):
try:
return pm.ToolCall(
id=item['call_id'],
type='function',
function=pm.FunctionCall(name=item['name'], arguments=item.get('arguments') or ''),
)
except (KeyError, ValueError, TypeError):
raise ValueError('Codex returned an invalid function call') from None
def _usage(response):
usage = response.get('usage') or {}
return {
'prompt_tokens': usage.get('input_tokens', 0),
'completion_tokens': usage.get('output_tokens', 0),
'total_tokens': usage.get('total_tokens', usage.get('input_tokens', 0) + usage.get('output_tokens', 0)),
'prompt_tokens_details': usage.get('input_tokens_details', {}),
'completion_tokens_details': usage.get('output_tokens_details', {}),
}
class CodexRequester(requester.ProviderAPIRequester):
async def initialize(self):
self.auth = CodexAuth(self.ap)
self.workspace = self.requester_cfg['workspace_uuid']
self.provider = self.requester_cfg['provider_uuid']
# Opaque replay data stays server-side; handles are scoped to the same query,
# model and OAuth connection. No token or encrypted reasoning enters messages.
self._replay = OrderedDict()
async def aclose(self):
self._replay.clear()
def get_reasoning_capabilities(self, model):
return {
'supported': True,
'levels': ['provider_default', 'low', 'medium', 'high', 'xhigh'],
'source': 'provider',
}
@staticmethod
def _headers(tokens, *, stream=False):
return {
'Authorization': 'Bearer ' + tokens['access_token'],
'ChatGPT-Account-ID': tokens['account_id'],
'User-Agent': 'LangBot/' + langbot.__version__,
'originator': 'langbot',
'OpenAI-Beta': 'responses=experimental',
'Accept': 'text/event-stream' if stream else 'application/json',
}
@staticmethod
def _http_error(status):
if status == 401:
# Upstream authentication is not LangBot authentication: HTTP401 would
# make the browser discard its own valid user session.
return CodexProviderError(LOGIN_REQUIRED, 400, 'codex_reauthentication_required')
if status == 429:
return CodexProviderError(
'ChatGPT request was limited (rate limit or usage restriction). Please retry later or check your plan.',
429,
'codex_rate_limited',
)
if status == 403:
return CodexProviderError(
'ChatGPT denied this request. Check subscription and workspace permissions.',
403,
'codex_access_denied',
)
if status == 400:
return CodexProviderError(
'ChatGPT rejected the model or request. Check the selected model and request settings.',
400,
'codex_invalid_request',
)
return CodexProviderError('ChatGPT Codex upstream request failed. Please retry later.')
async def _response_error(self, response):
# Inspect only a bounded 429 error record and an allowlisted machine code.
# Never expose upstream prose, reset metadata, headers or credentials.
if response.status_code == 429:
payload = bytearray()
async for chunk in response.aiter_bytes():
if len(payload) + len(chunk) > 8192:
return self._http_error(429)
payload.extend(chunk)
try:
data = json.loads(payload)
error = data.get('error') if isinstance(data, dict) else None
if isinstance(error, dict) and (
error.get('type') == 'usage_limit_reached' or error.get('code') == 'usage_limit_reached'
):
return CodexProviderError(
'ChatGPT subscription usage limit reached. Please retry later or check your plan.',
429,
'codex_usage_limit_reached',
)
except (ValueError, UnicodeError):
pass
return self._http_error(response.status_code)
def _scope(self, query, model, tokens):
return (
id(query),
getattr(query, 'query_id', None),
model.model_entity.name,
tokens.get('connection_id'),
tokens['account_id'],
)
def _body(self, query, model, messages, funcs, extra_args, tokens):
args = {**(model.model_entity.extra_args or {}), **(extra_args or {})}
# Never permit credentials, transport overrides, store/history or arbitrary
# SDK kwargs to be smuggled through model advanced parameters.
allowed = {'reasoning', 'text', 'parallel_tool_calls', 'tool_choice'}
unknown = set(args) - allowed
if unknown:
raise ValueError('Unsupported Codex advanced parameters: ' + ', '.join(sorted(unknown)))
instructions = []
items = []
scope = self._scope(query, model, tokens)
for message in messages:
if message.role in ('system', 'developer'):
instructions.append('\n'.join(p['text'] for p in _content(message) if 'text' in p))
continue
if message.role == 'tool':
if not message.tool_call_id:
raise ValueError('Codex tool results require a tool_call_id')
output = (
message.content
if isinstance(message.content, str)
else json.dumps([p.model_dump(exclude_none=True) for p in message.content or []])
)
items.append({'type': 'function_call_output', 'call_id': message.tool_call_id, 'output': output or ''})
continue
if message.role not in ('assistant', 'user'):
raise ValueError('Unsupported Codex message role')
handle = (message.provider_specific_fields or {}).get('codex_replay_id')
cached = self._replay.get(handle) if isinstance(handle, str) else None
if query is not None and cached and cached[0] == scope and cached[1] > time.time():
items.extend(cached[2])
continue
content = _content(message)
if content:
items.append({'type': 'message', 'role': message.role, 'content': content})
for call in message.tool_calls or []:
items.append(
{
'type': 'function_call',
'call_id': call.id,
'name': call.function.name,
'arguments': call.function.arguments,
}
)
body = {
**args,
'model': model.model_entity.name,
'instructions': '\n\n'.join(instructions),
'input': items,
'store': False,
'stream': True,
'include': ['reasoning.encrypted_content'],
}
reasoning_config = getattr(model, 'reasoning_config_override', None)
if reasoning_config is None:
reasoning_config = getattr(model.model_entity, 'reasoning_config', None)
level = reasoning.normalize_reasoning_config(reasoning_config)['level']
if level != 'provider_default':
reasoning.validate_reasoning_capabilities(
{'level': level}, self.get_reasoning_capabilities(model), model.model_entity.name
)
body['reasoning'] = {'effort': level, 'summary': 'auto'}
if funcs:
body['tools'] = [
{
'type': 'function',
'name': f.name,
'description': f.description,
'parameters': f.parameters,
'strict': False,
}
for f in funcs
]
return body
async def _events(self, query, model, messages, funcs, extra_args):
tokens = await self.auth.access(self.workspace, self.provider)
try:
async with asyncio.timeout(300), httpx.AsyncClient(timeout=120, follow_redirects=False) as client:
for attempt in range(2):
body = self._body(query, model, messages, funcs, extra_args, tokens)
async with client.stream(
'POST', BASE_URL + '/responses', json=body, headers=self._headers(tokens, stream=True)
) as response:
if response.status_code == 401 and attempt == 0:
tokens = await self.auth.access(
self.workspace, self.provider, rejected_token=tokens['access_token']
)
continue
if response.status_code != 200:
raise await self._response_error(response)
async for event in sse_events(response):
yield event, tokens
return
except (httpx.HTTPError, TimeoutError):
raise ValueError('ChatGPT Codex network error or timeout. Please retry.') from None
async def _chunks(self, query, model, messages, funcs, extra_args, remove_think, usage_out):
text = ''
seen_calls = set()
output_items = {}
response_id = None
async for event, tokens in self._events(query, model, messages, funcs, extra_args):
kind = event.get('type')
response = event.get('response') or {}
response_id = response.get('id') or response_id
if kind in ('error', 'response.failed', 'response.incomplete'):
raise CodexProviderError('ChatGPT Codex response failed or was incomplete. Please retry.')
if kind == 'response.output_text.delta':
delta = event.get('delta', '')
text += delta
yield pm.MessageChunk(role='assistant', content=delta, resp_message_id=response_id)
elif kind in ('response.reasoning_summary_text.delta', 'response.reasoning_text.delta'):
if not remove_think:
yield pm.MessageChunk(
role='assistant',
content='',
provider_specific_fields={'reasoning_content': event.get('delta', '')},
)
elif kind == 'response.output_item.done':
item = event.get('item') or {}
output_items[event.get('output_index', len(output_items))] = item
if item.get('type') == 'function_call' and item.get('call_id') not in seen_calls:
seen_calls.add(item.get('call_id'))
yield pm.MessageChunk(role='assistant', content='', tool_calls=[_tool(item)])
elif kind in ('response.completed', 'response.done'):
if response.get('status') not in (None, 'completed'):
raise CodexProviderError('ChatGPT Codex response was not completed')
output = response.get('output') or [output_items[k] for k in sorted(output_items)]
for item in output:
if item.get('type') == 'function_call' and item.get('call_id') not in seen_calls:
seen_calls.add(item.get('call_id'))
yield pm.MessageChunk(role='assistant', content='', tool_calls=[_tool(item)])
# Some servers send only the terminal output, without text deltas.
final_text = ''.join(
p.get('text', '')
for item in output
if item.get('type') == 'message'
for p in item.get('content', [])
if p.get('type') == 'output_text'
)
if not text and final_text:
text = final_text
yield pm.MessageChunk(role='assistant', content=text, resp_message_id=response_id)
usage_out.update(_usage(response))
if query is not None:
if query.variables is None:
query.variables = {}
query.variables[requester.STREAM_USAGE_QUERY_VARIABLE] = dict(usage_out)
fields = None
if query is not None and output:
handle = secrets.token_urlsafe(24)
self._replay[handle] = (self._scope(query, model, tokens), time.time() + 3600, output)
while len(self._replay) > 64:
self._replay.popitem(last=False)
fields = {'codex_replay_id': handle}
yield pm.MessageChunk(
role='assistant',
content='',
all_content=text,
is_final=True,
resp_message_id=response_id,
provider_specific_fields=fields,
)
return
raise CodexProviderError('ChatGPT Codex stream ended before completion. Please retry.')
async def invoke_llm_stream(self, query, model, messages, funcs=None, extra_args=None, remove_think=False):
async for chunk in self._chunks(query, model, messages, funcs, extra_args, remove_think, {}):
yield chunk
async def invoke_llm(self, query, model, messages, funcs=None, extra_args=None, remove_think=False):
usage = {}
text = ''
calls = []
fields = {}
response_id = None
async for chunk in self._chunks(query, model, messages, funcs, extra_args, remove_think, usage):
text += chunk.content or ''
calls.extend(chunk.tool_calls or [])
response_id = chunk.resp_message_id or response_id
for key, value in (chunk.provider_specific_fields or {}).items():
fields[key] = fields.get(key, '') + value if key == 'reasoning_content' else value
return pm.Message(
role='assistant',
content=text,
tool_calls=calls or None,
resp_message_id=response_id,
provider_specific_fields=fields or None,
), usage
async def scan_models(self, api_key=None):
tokens = await self.auth.access(self.workspace, self.provider)
try:
async with asyncio.timeout(90), httpx.AsyncClient(timeout=30, follow_redirects=False) as client:
for attempt in range(2):
response = await client.get(
BASE_URL + '/models',
params={'client_version': langbot.__version__},
headers=self._headers(tokens),
)
if response.status_code == 401 and attempt == 0:
tokens = await self.auth.access(
self.workspace, self.provider, rejected_token=tokens['access_token']
)
continue
if response.status_code != 200:
raise await self._response_error(response)
data = response.json()
if not isinstance(data, dict) or not isinstance(data.get('models'), list):
raise ValueError('ChatGPT returned an invalid model catalog')
result = {}
for item in data['models']:
name = item.get('slug') or item.get('id')
if not isinstance(name, str) or not name or item.get('visibility') == 'hide':
continue
modalities = item.get('input_modalities') or ['text']
abilities = ['func_call']
if 'image' in modalities:
abilities.append('vision')
if item.get('supported_reasoning_levels'):
abilities.append('reasoning')
result[name] = {
'id': name,
'name': name,
'type': 'llm',
'abilities': abilities,
'display_name': item.get('display_name'),
'description': item.get('description'),
'context_length': item.get('context_window'),
'input_modalities': modalities,
'output_modalities': ['text'],
'owned_by': 'openai',
}
return {'models': list(result.values()), 'debug': None}
except (httpx.HTTPError, TimeoutError):
raise ValueError('ChatGPT model discovery network error. Please retry.') from None
except (ValueError, TypeError, KeyError, AttributeError) as exc:
# Never echo upstream response bodies (which may contain credentials).
if isinstance(exc, ValueError) and str(exc).startswith(('ChatGPT', 'Codex')):
raise
raise ValueError('ChatGPT returned an invalid model catalog') from None
@@ -0,0 +1,27 @@
apiVersion: v1
kind: LLMAPIRequester
metadata:
name: openai-codex
label:
en_US: OpenAI Codex
zh_Hans: OpenAI Codex
ja_JP: OpenAI Codex
icon: openai.svg
spec:
config:
- name: base_url
label:
en_US: ChatGPT endpoint
zh_Hans: ChatGPT 服务地址
ja_JP: ChatGPT エンドポイント
type: string
required: false
default: https://chatgpt.com/backend-api/codex
alias: "openai codex ChatGPT subscription OAuth 订阅"
support_type:
- llm
provider_category: manufacturer
execution:
python:
path: ./codex.py
attr: CodexRequester
@@ -573,7 +573,7 @@ class LiteLLMRequester(requester.ProviderAPIRequester):
levels = ['provider_default', 'disabled', 'enabled']
elif family == 'doubao':
levels = ['provider_default', 'disabled', 'low', 'medium', 'high']
elif family == 'ollama':
elif family in ('ollama', 'ollama_chat'):
levels = ['provider_default']
levels.append('disabled')
if normalized_name.startswith('gpt-oss') or '/gpt-oss' in normalized_name:
@@ -1379,7 +1379,14 @@ class LiteLLMRequester(requester.ProviderAPIRequester):
extra_args: dict[str, typing.Any] = {},
) -> tuple[list[list[float]], dict]:
"""Invoke embedding and return vectors with usage info."""
model_name = self._build_litellm_model_name(model.model_entity.name)
# litellm's embedding routing has no "ollama_chat" branch (that provider
# exists only for /api/chat completions) — embeddings still go through
# the plain "ollama" provider. Requesters configured for ollama_chat
# (to get native tool-calling on the chat path) must fall back to
# "ollama" here specifically, or embedding calls raise "Unmapped LLM
# provider for this endpoint".
embedding_provider = 'ollama' if self._get_custom_llm_provider() == 'ollama_chat' else None
model_name = self._build_litellm_model_name(model.model_entity.name, embedding_provider)
api_key = model.provider.token_mgr.get_token()
args = {
@@ -1575,6 +1582,12 @@ class LiteLLMRequester(requester.ProviderAPIRequester):
event_hooks=httpclient.httpx_response_limit_hooks(),
) as client:
response = await client.get(models_url, headers=headers)
if response.status_code == 404 and not base_url.rstrip('/').endswith('/v1'):
# Some OpenAI-compatible servers (notably a bare Ollama host,
# e.g. http://host:11434) expose the model list under /v1/models
# rather than /models. Providers whose configured base_url
# already ends in /v1 keep their original (working) URL.
response = await client.get(f'{base_url}/v1/models', headers=headers)
response.raise_for_status()
payload = await httpclient.parse_json_response(response)
@@ -7,7 +7,7 @@ metadata:
zh_Hans: Ollama
icon: ollama.svg
spec:
litellm_provider: ollama
litellm_provider: ollama_chat
config:
- name: base_url
label:
@@ -1,5 +1,8 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" role="img" aria-label="TokenLab">
<rect width="64" height="64" rx="14" fill="#111827"/>
<path fill="#38bdf8" d="M17 14h30v8H36v28h-8V22H17z"/>
<path fill="#a7f3d0" d="M40 30h8v20H28v-8h12z"/>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 48" role="img" aria-labelledby="tokenlab-symbol-title tokenlab-symbol-desc">
<title id="tokenlab-symbol-title">TokenLab</title>
<desc id="tokenlab-symbol-desc">Specimen Split symbol, positive master for sizes from 32 to 96 pixels.</desc>
<g fill="#151714">
<path d="M5 13h14.25L35 43H5V13Z"/>
<path d="M20.7 5H43v30H35.6L20.7 5Z"/>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 265 B

After

Width:  |  Height:  |  Size: 416 B

+56 -1
View File
@@ -1,5 +1,6 @@
from __future__ import annotations
import dataclasses
import enum
import json
import math
@@ -208,6 +209,13 @@ class MCPSessionStatus(enum.Enum):
ERROR = 'error'
@dataclasses.dataclass(frozen=True)
class MCPOAuthChallenge:
"""Bearer challenge metadata returned by an OAuth-protected MCP server."""
resource_metadata_url: str | None
class _TransportReconnect(Exception):
"""Internal signal: the Box stdio WS transport dropped but the managed
process is still alive. Triggers a lightweight transport reconnect that
@@ -267,6 +275,7 @@ class RuntimeMCPSession:
_ready_event: asyncio.Event
error_message: str | None = None
_public_error_code: str = 'runtime_error'
error_phase: MCPSessionErrorPhase | None = None
@@ -513,6 +522,13 @@ class RuntimeMCPSession:
await self._init_streamable_http_server()
return
except Exception as e:
if self._extract_oauth_challenge(e) is not None:
self.error_phase = MCPSessionErrorPhase.OAUTH_REQUIRED
self.ap.logger.info(
f'MCP server {self.server_name}: remote server requires OAuth authorization; '
'not falling back to SSE'
)
raise
if not self._should_fallback_to_sse(e):
self.ap.logger.info(
f'MCP server {self.server_name}: Streamable HTTP transport failed '
@@ -636,6 +652,7 @@ class RuntimeMCPSession:
except Exception as e:
self.status = MCPSessionStatus.ERROR
self.error_message = str(e)
self._public_error_code = self._classify_public_error(e)
self.ap.logger.error(f'Error in MCP session lifecycle {self.server_name}: {e}\n{traceback.format_exc()}')
# Do NOT set _ready_event here — let _lifecycle_loop_with_retry
# handle retries first. It will set the event when all retries
@@ -759,6 +776,11 @@ class RuntimeMCPSession:
self.retry_count += 1
if self._shutdown_event.is_set():
return # Shutdown requested, don't retry
if self.error_phase == MCPSessionErrorPhase.OAUTH_REQUIRED:
self.retry_count = attempt + 1
self.status = MCPSessionStatus.ERROR
self._ready_event.set()
return
# A lifecycle that reached CONNECTED proved its preceding
# startup attempt was healthy. Give a later runtime failure a
# fresh consecutive-startup retry budget instead of exhausting
@@ -897,6 +919,39 @@ class RuntimeMCPSession:
else:
yield exc
@staticmethod
def _classify_public_error(exc: BaseException) -> str:
"""Expose a safe category without transport URLs, headers, or arguments."""
for leaf in RuntimeMCPSession._iter_exception_leaves(exc):
if isinstance(leaf, httpx.HTTPStatusError):
return f'http_{leaf.response.status_code}'
if isinstance(leaf, (httpx.TimeoutException, TimeoutError)):
return 'connection_timeout'
if isinstance(leaf, httpx.ConnectError):
return 'connection_unreachable'
return 'runtime_error'
@staticmethod
def _extract_oauth_challenge(exc: BaseException) -> MCPOAuthChallenge | None:
"""Extract an OAuth Bearer challenge from a remote MCP connection failure."""
for leaf in RuntimeMCPSession._iter_exception_leaves(exc):
if not isinstance(leaf, httpx.HTTPStatusError) or leaf.response.status_code != 401:
continue
for header in leaf.response.headers.get_list('www-authenticate'):
bearer_match = re.search(r'(?:^|,)\s*Bearer(?:\s|,|$)', header, flags=re.IGNORECASE)
if bearer_match is None:
continue
metadata_match = re.search(
r'(?:^|,)\s*resource_metadata\s*=\s*(?:"([^"]+)"|([^,\s]+))',
header[bearer_match.end() :],
flags=re.IGNORECASE,
)
if metadata_match is None:
continue
resource_metadata_url = metadata_match.group(1) or metadata_match.group(2)
return MCPOAuthChallenge(resource_metadata_url=resource_metadata_url)
return None
@staticmethod
def _should_fallback_to_sse(exc: BaseException) -> bool:
"""Whether a Streamable HTTP failure matches legacy-SSE fallback.
@@ -1439,7 +1494,7 @@ class RuntimeMCPSession:
# environment values. Detailed diagnostics belong in AUDIT_VIEW
# logs; resource-list responses expose only a stable status.
'error_message': 'MCP runtime failed' if self.error_message else None,
'error_code': 'runtime_error' if self.error_message else None,
'error_code': self._public_error_code if self.error_message else None,
'error_phase': self.error_phase.value if self.error_phase else None,
'retry_count': self.retry_count,
'tool_count': len(self.get_tools()),
@@ -52,6 +52,7 @@ class MCPSessionErrorPhase(enum.Enum):
MCP_INIT = 'mcp_init'
RUNTIME = 'runtime'
TOOL_CALL = 'tool_call'
OAUTH_REQUIRED = 'oauth_required'
# Stdio MCP refused because Box is disabled in config or currently
# unavailable. Not transient — retries would be pointless. The frontend
# uses this phase to render a localized actionable message instead of
@@ -223,6 +223,7 @@ class NativeToolLoader(loader.ToolLoader):
self.ap.logger.warning(
'Native sandbox tools (exec/read/write/edit/glob/grep) are NOT available. '
'No sandbox backend (Docker/nsjail/E2B) is ready. '
'Trusted local development may explicitly select box.backend=host. '
'The LLM will not have access to code execution or file operation tools.'
)
@@ -263,6 +264,9 @@ class NativeToolLoader(loader.ToolLoader):
return name in _ALL_TOOL_NAMES and await self._is_sandbox_available()
async def invoke_tool(self, name: str, parameters: dict, query: pipeline_query.Query):
from ....box.runner import binding_for
binding_for(query)
require_sandbox = getattr(
getattr(self.ap, 'box_service', None),
'require_workspace_sandbox',
@@ -42,7 +42,8 @@ class SkillToolLoader(loader.ToolLoader):
else:
self.ap.logger.info(
'Skill tools (activate/register_skill) are NOT available. '
'No sandbox backend (Docker/nsjail/E2B) is ready.'
'No sandbox backend (Docker/nsjail/E2B) is ready. '
'Trusted local development may explicitly select box.backend=host.'
)
async def _check_sandbox_available(self) -> bool:
+231
View File
@@ -0,0 +1,231 @@
from __future__ import annotations
import asyncio
import base64
import copy
import datetime
import mimetypes
import os
import re
import xxhash
from pathlib import Path
from typing import TYPE_CHECKING, Any
if TYPE_CHECKING:
from ...core import app
from . import mgr as storage_mgr
DEFAULT_RETENTION_DAYS = 30
DEFAULT_MAX_SIZE_MB = 0
MEDIA_DIR = 'media_cache'
SAFE_MEDIA_FILENAME = re.compile(r'^[a-f0-9]{32,64}(\.[a-zA-Z0-9]{1,10})?$')
class MediaCache:
"""Content-addressable storage cache for images and media attachments.
Deduplicates media files using xxHash3-128,
offloads payloads from SQLite to StorageProvider, and implements LRU
and age-based retention cleanup.
"""
def __init__(self, ap: app.Application, storage_mgr: storage_mgr.StorageMgr):
self.ap = ap
self.storage_mgr = storage_mgr
@staticmethod
def hash_bytes(data: bytes) -> str:
"""Compute content-addressable hash for binary data."""
return xxhash.xxh3_128_hexdigest(data)
@staticmethod
def parse_data_url(data_url: str) -> tuple[bytes, str] | None:
"""Parse a data URL or raw base64 string into bytes and mime type."""
if not data_url or not isinstance(data_url, str):
return None
try:
if data_url.startswith('data:'):
split_index = data_url.find(';base64,')
if split_index != -1:
mime_type = data_url[5:split_index]
b64_data = data_url[split_index + 8 :]
return base64.b64decode(b64_data), mime_type
# Try raw base64 if sufficiently long
if len(data_url) > 20 and not data_url.startswith(('http://', 'https://', '/')):
return base64.b64decode(data_url), 'application/octet-stream'
except Exception:
return None
return None
@staticmethod
def guess_extension(mime_type: str | None, default: str = '.jpg') -> str:
"""Guess appropriate file extension from MIME type."""
if not mime_type:
return default
mime_lower = mime_type.lower()
if 'png' in mime_lower:
return '.png'
if 'webp' in mime_lower:
return '.webp'
if 'gif' in mime_lower:
return '.gif'
if 'jpeg' in mime_lower or 'jpg' in mime_lower:
return '.jpg'
ext = mimetypes.guess_extension(mime_type)
if ext == '.jpe':
return '.jpg'
return ext or default
async def save_media(self, data: bytes, mime_type: str | None = None) -> tuple[str, str, int]:
"""Save media bytes into content-addressable storage cache.
Returns:
Tuple of (hash_str, storage_key, byte_size)
"""
hash_str = self.hash_bytes(data)
ext = self.guess_extension(mime_type)
storage_key = f'{MEDIA_DIR}/{hash_str}{ext}'
provider = self.storage_mgr.storage_provider
if not await provider.exists(storage_key):
await provider.save(storage_key, data)
else:
await self.touch(storage_key)
return hash_str, storage_key, len(data)
async def get_media(self, filename_or_key: str) -> tuple[bytes, str] | None:
"""Retrieve media bytes and mime type by key or filename."""
filename = os.path.basename(filename_or_key)
if not SAFE_MEDIA_FILENAME.match(filename):
return None
storage_key = f'{MEDIA_DIR}/{filename}'
provider = self.storage_mgr.storage_provider
if not await provider.exists(storage_key):
return None
data = await self.storage_mgr._load_object_bounded(storage_key)
mime_type = mimetypes.guess_type(filename)[0] or 'application/octet-stream'
await self.touch(storage_key)
return data, mime_type
async def touch(self, storage_key: str) -> None:
"""Update access/modified time of a media file for LRU tracking."""
provider = getattr(self.storage_mgr, 'storage_provider', None)
if provider is not None and provider.__class__.__name__ == 'LocalStorageProvider':
full_path = os.path.join('data', 'storage', storage_key)
if os.path.exists(full_path):
now = datetime.datetime.now().timestamp()
try:
await asyncio.to_thread(os.utime, full_path, (now, now))
except Exception:
# Failures to update mtime are intentionally ignored because LRU touch is opportunistic.
pass
async def cleanup(
self,
retention_days: int = DEFAULT_RETENTION_DAYS,
max_size_mb: int = DEFAULT_MAX_SIZE_MB,
) -> dict[str, int]:
"""Perform age-based and LRU size-based cleanup on media cache.
Args:
retention_days: Retain media accessed within this many days (default 30).
max_size_mb: Maximum total size in MB (0 means unlimited).
Returns:
Dictionary of cleanup metrics.
"""
provider = getattr(self.storage_mgr, 'storage_provider', None)
if provider is None or provider.__class__.__name__ != 'LocalStorageProvider':
return {'expired_deleted': 0, 'size_deleted': 0, 'bytes_freed': 0}
target_dir = Path('data/storage') / MEDIA_DIR
if not target_dir.exists() or not target_dir.is_dir():
return {'expired_deleted': 0, 'size_deleted': 0, 'bytes_freed': 0}
now = datetime.datetime.now().timestamp()
cutoff = (now - retention_days * 86400) if retention_days > 0 else 0
expired_deleted = 0
size_deleted = 0
bytes_freed = 0
remaining: list[tuple[Path, int, float]] = []
for entry in target_dir.iterdir():
if not entry.is_file():
continue
try:
stat = entry.stat()
except OSError:
continue
if cutoff > 0 and stat.st_mtime < cutoff:
try:
entry.unlink(missing_ok=True)
expired_deleted += 1
bytes_freed += stat.st_size
except OSError:
# Best-effort cleanup; file may already be gone or temporarily inaccessible.
pass
else:
remaining.append((entry, stat.st_size, stat.st_mtime))
if max_size_mb > 0:
max_bytes = max_size_mb * 1024 * 1024
total_bytes = sum(item[1] for item in remaining)
if total_bytes > max_bytes:
remaining.sort(key=lambda item: item[2])
for path, size, _ in remaining:
if total_bytes <= max_bytes:
break
try:
path.unlink(missing_ok=True)
size_deleted += 1
bytes_freed += size
total_bytes -= size
except OSError:
# Best-effort cleanup; file may already be gone or temporarily inaccessible.
continue
return {
'expired_deleted': expired_deleted,
'size_deleted': size_deleted,
'bytes_freed': bytes_freed,
}
async def externalize_chain_dump(self, chain_dump: Any) -> Any:
"""Recursively extract raw base64 media into cache and replace with references."""
if isinstance(chain_dump, list):
return [await self.externalize_chain_dump(item) for item in chain_dump]
if isinstance(chain_dump, dict):
node = copy.copy(chain_dump)
node_type = node.get('type')
if node_type == 'Image':
b64 = node.get('base64')
if b64 and isinstance(b64, str):
try:
parsed = self.parse_data_url(b64)
if parsed is not None:
raw_bytes, mime_type = parsed
hash_str, storage_key, size = await self.save_media(raw_bytes, mime_type)
filename = os.path.basename(storage_key)
current_url = node.get('url') or ''
if current_url and not current_url.startswith('data:'):
node['original_url'] = current_url
node['url'] = f'/api/v1/files/media/{filename}'
node['base64'] = None
node['hash'] = hash_str
node['storage_key'] = storage_key
node['size'] = size
node['mime_type'] = mime_type
except Exception as e:
if hasattr(self.ap, 'logger') and self.ap.logger:
self.ap.logger.warning(f'Failed to externalize image to media cache: {e}')
node['base64'] = None
for k, v in list(node.items()):
if isinstance(v, (list, dict)):
node[k] = await self.externalize_chain_dump(v)
return node
return chain_dump
+3
View File
@@ -34,6 +34,9 @@ class StorageMgr:
def __init__(self, ap: app.Application):
self.ap = ap
from . import media
self.media_cache = media.MediaCache(ap, self)
def _object_read_limit(self) -> int:
config = getattr(getattr(self.ap, 'instance_config', None), 'data', {})
+1 -1
View File
@@ -83,7 +83,7 @@ class VersionManager:
try:
if await self.is_new_version_available():
return (
'New version available. Update guide: https://link.langbot.app/en/docs/update',
'New version available. Update guide: https://langbot.app/docs/en/deploy/update',
logging.INFO,
)
except Exception as e:
+25 -26
View File
@@ -101,18 +101,6 @@ class SeekDBVectorDatabase(VectorDatabase):
self._collection_configs: Dict[str, HNSWConfiguration] = {}
self._runtime_cache_limit = runtime_cache_limit(ap)
self._escape_table = str.maketrans(
{
'\x00': '',
'\\': '\\\\',
"'": "''", # Standard SQL escaping (OceanBase NO_BACKSLASH_ESCAPES)
'"': '\\"',
'\n': '\\n',
'\r': '\\r',
'\t': '\\t',
}
)
async def close(self) -> None:
self._collections.clear()
self._collection_configs.clear()
@@ -192,16 +180,22 @@ class SeekDBVectorDatabase(VectorDatabase):
return coll
def _clean_metadata(self, meta: Dict[str, Any]) -> Dict[str, Any]:
"""SeekDB metadata doesn't support \\ and ", insert will error 3104"""
return {
k: v.translate(self._escape_table)
if isinstance(v, str)
else v
if v is None or isinstance(v, (int, float, bool))
else str(v)
for k, v in meta.items()
if v is not None
}
"""Keep supported scalar metadata values without altering strings."""
return {k: v if isinstance(v, (str, int, float, bool)) else str(v) for k, v in meta.items() if v is not None}
@staticmethod
def _relevance_scores_to_distances(results: Dict[str, Any]) -> None:
"""Convert SeekDB hybrid relevance scores to lower-is-better distances."""
distances = results.get('distances')
if not isinstance(distances, list):
return
results['distances'] = [
[1.0 - float(score) if isinstance(score, (int, float)) else score for score in batch]
if isinstance(batch, list)
else batch
for batch in distances
]
async def get_or_create_collection(self, collection: str):
"""Get or create collection (without vector size - will use default)."""
@@ -236,10 +230,10 @@ class SeekDBVectorDatabase(VectorDatabase):
kwargs: Dict[str, Any] = dict(ids=ids, embeddings=embeddings_list, metadatas=cleaned_metadatas)
if documents is not None:
kwargs['documents'] = [doc.translate(self._escape_table) for doc in documents]
await asyncio.to_thread(coll.add, **kwargs)
kwargs['documents'] = documents
await asyncio.to_thread(coll.upsert, **kwargs)
self.ap.logger.info(f"Added {len(ids)} embeddings to SeekDB collection '{collection}'")
self.ap.logger.info(f"Upserted {len(ids)} embeddings into SeekDB collection '{collection}'")
async def search(
self,
@@ -287,7 +281,8 @@ class SeekDBVectorDatabase(VectorDatabase):
# Route by search type.
# pyseekdb's query() always requires embeddings, so full-text and
# hybrid modes use hybrid_search() which supports text-only queries
# and returns the same nested-list format with distances.
# and returns relevance scores in the nested ``distances`` field.
returns_relevance_scores = False
if search_type == SearchType.FULL_TEXT:
if not query_text:
return {'ids': [[]], 'metadatas': [[]], 'distances': [[]]}
@@ -309,6 +304,7 @@ class SeekDBVectorDatabase(VectorDatabase):
n_results=k,
include=['documents', 'metadatas'],
)
returns_relevance_scores = True
elif search_type == SearchType.HYBRID:
if not query_text:
@@ -352,6 +348,7 @@ class SeekDBVectorDatabase(VectorDatabase):
n_results=k,
include=['documents', 'metadatas'],
)
returns_relevance_scores = True
self.ap.logger.info(
f"SeekDB hybrid search in '{collection}' returned {len(results.get('ids', [[]])[0])} results."
)
@@ -363,6 +360,8 @@ class SeekDBVectorDatabase(VectorDatabase):
results = await asyncio.to_thread(coll.query, **query_kwargs)
results = self._json_safe(results)
if returns_relevance_scores:
self._relevance_scores_to_distances(results)
self.ap.logger.info(
f"SeekDB {search_type} search in '{collection}' returned {len(results.get('ids', [[]])[0])} results"
)
@@ -269,7 +269,7 @@ class InvitationDeliveryService:
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" border="0" style="width:100%;max-width:600px;">
<tr>
<td style="padding:0 4px 20px;">
<img src="https://docs.langbot.app/langbot-logo.png" alt="LangBot" width="34" height="34" style="display:inline-block;width:34px;height:34px;border:0;vertical-align:middle;">
<img src="https://langbot.app/docs/langbot-logo.png" alt="LangBot" width="34" height="34" style="display:inline-block;width:34px;height:34px;border:0;vertical-align:middle;">
<span style="display:inline-block;margin-left:10px;vertical-align:middle;font-size:18px;font-weight:700;letter-spacing:-.01em;">LangBot</span>
</td>
</tr>
+10 -7
View File
@@ -106,12 +106,6 @@ system:
max_pipelines: -1
max_extensions: -1
max_knowledge_bases: -1
# When set to a non-empty string, every pipeline is forced to use this
# Box sandbox-scope template regardless of its own configuration, and
# the per-pipeline "Sandbox Scope" selector is locked in the web UI.
# Used by SaaS deployments to confine a tenant to a single shared
# sandbox (set to '{global}'). Empty string = no restriction.
force_box_session_id_template: ''
task_retention:
# Keep at most this many completed async task records in memory
completed_limit: 200
@@ -227,6 +221,12 @@ storage:
# Bound every object materialized into Core memory. Built-in Local/S3
# providers enforce this while reading (hard cap: 64 MiB).
max_object_read_bytes: 10485760
# Media content cache (images & attachments externalized from monitoring and pipelines)
media_cache:
# Retention period in days for cached media (defaults to 30 days)
retention_days: 30
# Maximum disk storage for cached media in MB (0 means unlimited, defaults to 0)
max_size_mb: 0
cleanup:
# Enable periodic cleanup of local/S3 uploaded files and old log files
enabled: true
@@ -344,7 +344,10 @@ box:
# skill tool, skill add/edit, and stdio-mode MCP servers. Skills can still
# be listed read-only and http/sse MCP servers continue to work.
enabled: true
backend: 'local' # 'local' (Docker/nsjail), 'docker', 'nsjail', or 'e2b'. Can be written via BOX__BACKEND.
# 'host' runs commands directly as the Box Runtime user without sandbox
# isolation. It is never auto-selected and is only for trusted local
# development. Can be written via BOX__BACKEND.
backend: 'local' # 'local' (Docker/nsjail), 'docker', 'nsjail', 'e2b', or explicit unsafe 'host'.
runtime:
# LANGBOT_BOX_CONTROL_TOKEN is optional for OSS external WebSocket
# runtimes. To protect an exposed endpoint, set the same strong secret
+9 -3
View File
@@ -645,9 +645,10 @@
.replace(/\s+/g, " ")
.trim();
if (
prevContent === content ||
prevContent.indexOf(content) >= 0 ||
content.indexOf(prevContent) >= 0
prevContent &&
(prevContent === content ||
prevContent.indexOf(content) >= 0 ||
content.indexOf(prevContent) >= 0)
)
return;
}
@@ -1265,6 +1266,11 @@
// Styles
var style = document.createElement("style");
style.textContent = STYLES;
// Keep the wizard navigation accessible while the chat preview is open.
if (scriptTestNotice) {
style.textContent +=
".lb-bubble { bottom: 84px; } .lb-panel { bottom: 152px; max-height: calc(100vh - 172px); } @media (max-width: 480px) { .lb-panel { bottom: 64px; height: calc(100vh - 64px); max-height: calc(100vh - 64px); } }";
}
shadow.appendChild(style);
// Chat bubble button