diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 6f5b39c01..14919cc2d 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -171,6 +171,23 @@ The Plugin Runtime supports stdio and WebSocket control transports. Direct local ## Box Runtime and Skills +Runner plugins own sandbox policy: enablement, reuse-key interpolation, acquisition, +binding, and explicit file import/export. The Host exposes authenticated resource +APIs (`get_box_status`, `list_boxes`, `acquire_box`) and run-bound operations +(`bind_box`, `import_box_attachments`, `export_box_files`, `reply_files`). Box Runtime +owns atomic capacity enforcement and container reuse/lifecycle. Reusing an existing +Box is allowed when no additional capacity remains. + +A run binds one Box before native sandbox tools or file transfer. The Host does not +choose a conversation scope or stage attachments before starting the Runner. Input +references retain the original attachment metadata; import yields paths specific to +the run. Output export reads only that run's outbox and returns opaque file handles. +`message.completed.file_ids` attaches explicitly exported files to Pipeline output; +Agents send them explicitly with `ctx.reply_files()`, subject to event reply permission. +The Pipeline wrapper never scans a Box. Binding ends with the run; the reusable Box +remains subject to Runtime idle expiry. Persistent workspace files survive expiry, +but processes and container-local state do not. + Box is the sandbox subsystem used by native agent tools, stdio MCP servers, skill authoring, and managed processes. In this repo: diff --git a/pyproject.toml b/pyproject.toml index e2c41e294..096787729 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -233,3 +233,6 @@ skip-magic-trailing-comma = false # Like Black, automatically detect the appropriate line ending. line-ending = "auto" + +[tool.uv.sources] +langbot-plugin = { git = "https://github.com/langbot-app/langbot-plugin-sdk", rev = "94c098ee0d1c4535043d9bf4a74c25f90849bc41" } diff --git a/src/langbot/pkg/agent/runner/context_builder.py b/src/langbot/pkg/agent/runner/context_builder.py index db8036a58..0f9d73729 100644 --- a/src/langbot/pkg/agent/runner/context_builder.py +++ b/src/langbot/pkg/agent/runner/context_builder.py @@ -371,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, @@ -520,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, diff --git a/src/langbot/pkg/agent/runner/execution_context.py b/src/langbot/pkg/agent/runner/execution_context.py index 0b60d5f4c..439cac40b 100644 --- a/src/langbot/pkg/agent/runner/execution_context.py +++ b/src/langbot/pkg/agent/runner/execution_context.py @@ -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: diff --git a/src/langbot/pkg/agent/runner/orchestrator.py b/src/langbot/pkg/agent/runner/orchestrator.py index 7e37b4afa..b1e1d18ef 100644 --- a/src/langbot/pkg/agent/runner/orchestrator.py +++ b/src/langbot/pkg/agent/runner/orchestrator.py @@ -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, ) @@ -128,6 +127,10 @@ class AgentRunOrchestrator: ): 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: @@ -167,6 +170,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'] @@ -366,6 +375,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 @@ -397,6 +417,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 [] @@ -428,12 +451,6 @@ class AgentRunOrchestrator: 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, @@ -445,48 +462,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) diff --git a/src/langbot/pkg/api/http/service/pipeline_migration.py b/src/langbot/pkg/api/http/service/pipeline_migration.py index f713ab52a..354725a16 100644 --- a/src/langbot/pkg/api/http/service/pipeline_migration.py +++ b/src/langbot/pkg/api/http/service/pipeline_migration.py @@ -15,6 +15,8 @@ import secrets import sys import uuid +from langbot_plugin.runtime.plugin.mgr import PluginInstallSource + import sqlalchemy as sa from ..authz import Permission, permissions_for_role, require_permission @@ -86,6 +88,7 @@ class PipelineMigrationService: 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( @@ -296,21 +299,33 @@ class PipelineMigrationService: 'total': len(items), } - async def _selected(self, ctx, selection): + 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, _ = await self._plan(ctx, row) + 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'): + 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) @@ -333,6 +348,140 @@ class PipelineMigrationService: ) 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', + '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' + try: + await self._authorize(ctx) + await self.ap.plugin_connector.require_workspace_context(execution) + # Keep installer diagnostics out of the user task: upstream + # errors may contain credentials. Reuse normal quota and + # runtime-readiness checks in the connector. + await self.ap.plugin_connector.install_plugin( + PluginInstallSource.MARKETPLACE, + { + 'plugin_author': target['author'], + 'plugin_name': target['name'], + 'plugin_version': target['version'], + }, + task_context=TaskContext.new(), + ) + installed[key] = None + except Exception: + installed[key] = 'plugin_install_failed' + 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=exc.code if isinstance(exc, MigrationError) else 'migration_failed', + ) + 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) @@ -528,11 +677,11 @@ class PipelineMigrationService: ]: await session.execute(sa.select(model.__table__).where(condition).with_for_update()) - async def _commit(self, ctx, selection, expected_facts, candidate): + 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) + row, plan, facts, pending = await self._selected(ctx, selection, data_only=data_only) if facts != expected_facts: raise MigrationError('plugin_changed') if pending: @@ -595,27 +744,30 @@ class PipelineMigrationService: .values(state='active') ) - async def _run(self, ctx, execution, items, task_context): + 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, task_context.metadata['results']): + 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) - runtime_schema = await self._verify_runtime(execution, plan) + 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 await self._verify_runtime(execution, plan) != runtime_schema: + 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) + 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=None) + 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 diff --git a/src/langbot/pkg/box/runner.py b/src/langbot/pkg/box/runner.py new file mode 100644 index 000000000..fa1b8aab6 --- /dev/null +++ b/src/langbot/pkg/box/runner.py @@ -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 + ] + } diff --git a/src/langbot/pkg/box/service.py b/src/langbot/pkg/box/service.py index 83d6473c3..6208f316d 100644 --- a/src/langbot/pkg/box/service.py +++ b/src/langbot/pkg/box/service.py @@ -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 @@ -584,6 +582,10 @@ class BoxService: 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) + 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) @@ -636,49 +638,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. @@ -826,6 +789,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: @@ -1837,16 +1803,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, ''): @@ -2073,51 +2029,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.""" diff --git a/src/langbot/pkg/pipeline/legacy_config_migration.py b/src/langbot/pkg/pipeline/legacy_config_migration.py index da9f32a8f..928c41aeb 100644 --- a/src/langbot/pkg/pipeline/legacy_config_migration.py +++ b/src/langbot/pkg/pipeline/legacy_config_migration.py @@ -21,7 +21,7 @@ from urllib.parse import urlsplit PLANNER_VERSION = '3' _TARGETS = { - 'local-agent': ('LocalAgent', '0.1.6'), + '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'), @@ -339,11 +339,12 @@ def _validate_local(result, section): _block(result, 'invalid_type', f'{prefix}.model') if not _valid_prompt(section.get('prompt')): _block(result, 'local.prompt_shape', f'{prefix}.prompt') - if section.get('box-session-id-template') not in (None, '', '{launcher_type}_{launcher_id}'): - _block(result, 'local.box_scope', f'{prefix}.box-session-id-template') _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, 'invalid_type', f'{prefix}.box-session-id-template') _warn(result, 'local.box_state_reset', f'{prefix}.box-session-id-template') @@ -543,7 +544,6 @@ def _assemble(result, legacy, section, config, preferences): selected['user-id-source'] = _IDENTITY_SOURCES[legacy] if legacy == 'local-agent': selected.pop('max-round', None) - selected.pop('box-session-id-template', None) singular = selected.pop('knowledge-base', '') if not selected['knowledge-bases'] and singular and singular != '__none__': selected['knowledge-bases'] = [singular] diff --git a/src/langbot/pkg/pipeline/process/handlers/chat.py b/src/langbot/pkg/pipeline/process/handlers/chat.py index 55596e29e..2fc7b0f82 100644 --- a/src/langbot/pkg/pipeline/process/handlers/chat.py +++ b/src/langbot/pkg/pipeline/process/handlers/chat.py @@ -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) diff --git a/src/langbot/pkg/pipeline/wrapper/wrapper.py b/src/langbot/pkg/pipeline/wrapper/wrapper.py index eff976bfa..eeb2daec2 100644 --- a/src/langbot/pkg/pipeline/wrapper/wrapper.py +++ b/src/langbot/pkg/pipeline/wrapper/wrapper.py @@ -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, diff --git a/src/langbot/pkg/plugin/box_actions.py b/src/langbot/pkg/plugin/box_actions.py new file mode 100644 index 000000000..e2ff8e8af --- /dev/null +++ b/src/langbot/pkg/plugin/box_actions.py @@ -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) diff --git a/src/langbot/pkg/plugin/connector.py b/src/langbot/pkg/plugin/connector.py index ee17c0200..f43bedfa8 100644 --- a/src/langbot/pkg/plugin/connector.py +++ b/src/langbot/pkg/plugin/connector.py @@ -1631,6 +1631,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 +1641,22 @@ 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, + ) + return package, version + mcp_status, mcp_body = await _marketplace_get( client, f'{space_url}/api/v1/marketplace/mcps/{plugin_author}/{plugin_name}', @@ -1726,11 +1743,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 diff --git a/src/langbot/pkg/plugin/handler.py b/src/langbot/pkg/plugin/handler.py index 03ec789fd..a1d1cdec4 100644 --- a/src/langbot/pkg/plugin/handler.py +++ b/src/langbot/pkg/plugin/handler.py @@ -1188,7 +1188,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') ) @@ -1208,9 +1219,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: @@ -2634,6 +2650,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) diff --git a/src/langbot/pkg/provider/tools/loaders/native.py b/src/langbot/pkg/provider/tools/loaders/native.py index 19833a912..602c22f62 100644 --- a/src/langbot/pkg/provider/tools/loaders/native.py +++ b/src/langbot/pkg/provider/tools/loaders/native.py @@ -264,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', diff --git a/src/langbot/templates/config.yaml b/src/langbot/templates/config.yaml index ac3c3ee37..1129c08e8 100644 --- a/src/langbot/templates/config.yaml +++ b/src/langbot/templates/config.yaml @@ -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 diff --git a/tests/e2e/test_local_runner_fake_provider.py b/tests/e2e/test_local_runner_fake_provider.py index e30cb3c2b..37a4b9044 100644 --- a/tests/e2e/test_local_runner_fake_provider.py +++ b/tests/e2e/test_local_runner_fake_provider.py @@ -1,7 +1,7 @@ """E2E coverage for the official Local Agent runner with fake Host resources. These tests start the real LangBot application and the real SDK Plugin Runtime, -load the sibling ``langbot-local-agent`` plugin, and verify Local Agent paths +load the consolidated ``langbot-plugin-demo/Runner/LocalAgent`` plugin, and verify Local Agent paths that must cross Host run-scoped APIs without calling any external provider. """ @@ -45,7 +45,7 @@ def _free_port() -> int: def _local_agent_repo() -> Path: """Return the sibling local-agent repository used by this workspace E2E.""" project_root = find_project_root() - return project_root.parent / 'langbot-local-agent' + return project_root.parent / 'langbot-plugin-demo' / 'Runner' / 'LocalAgent' def _package_local_agent_plugin(tmpdir: Path) -> Path: @@ -1010,3 +1010,125 @@ def test_local_runner_combines_rag_compaction_and_multi_turn_tool_loop( assert 'SUMMARY_COMBO compacted older history' in checkpoint['summary'] finally: conn.close() + + +def test_local_runner_owns_box_reuse_and_explicit_files( + local_agent_e2e_tmpdir, + local_agent_e2e_config_path, + local_agent_runtime_process, +): + """Real plugin RPC and Docker sandbox, with only the model scripted.""" + del local_agent_e2e_config_path, local_agent_runtime_process + import base64 + from langbot_plugin.box.backend import DockerBackend + from langbot_plugin.box.runtime import BoxRuntime + from langbot_plugin.api.entities.builtin.runner.input import InputAttachment + from langbot.pkg.box.service import BoxService + from langbot.pkg.box.runner import RunnerBoxService, binding_for + from tests.unit_tests.box.test_box_service import _InProcessBoxRuntimeClient + + class TestDockerBackend(DockerBackend): + async def cleanup_orphaned_containers(self, current_instance_id=''): + # This test must never clean up a developer's unrelated containers. + pass + + class Client(_InProcessBoxRuntimeClient): + async def get_status(self, *, action_context=None): + return {**await self._runtime.get_status(), 'capacity': await self._runtime.get_capacity(action_context)} + + async def create_session(self, spec, *, action_context=None): + return await self._runtime.create_session(spec, action_context=action_context) + + async def execute(self, spec, *, action_context=None): + return await self._runtime.execute(spec, action_context=action_context) + + async def get_sessions(self, *, action_context=None): + return self._runtime.get_sessions_for_workspace(action_context) + + class ToolManager(_FakeToolManager): + def __init__(self, box): + super().__init__() + self.box = box + self.bindings = [] + + async def get_resolved_tool_catalog(self, *args, **kwargs): + return [{'name': 'exec', 'source': 'native', 'source_id': None}] + + async def get_tool_schema(self, context, tool_name, source_ref=None): + return 'Copy the input into the output directory.', { + 'type': 'object', + 'properties': {'query': {'type': 'string'}}, + 'required': ['query'], + } + + async def execute_func_call(self, name, parameters, query=None, source_ref=None): + binding = binding_for(query) + self.bindings.append((binding.session_id, binding.run_id)) + source = next(iter(binding.imported.values()))['path'] + outbox = f'/workspace/outbox/{binding.io_scope}' + # Shell arguments are Host-issued paths, never model input. + import shlex + + result = await self.box.execute_tool( + { + 'command': f'mkdir -p {shlex.quote(outbox)} && cp {shlex.quote(source)} {shlex.quote(outbox + "/answer.txt")}' + }, + query, + ) + assert result['ok'], result + return result + + async def probe(ap): + backend = TestDockerBackend(ap.logger) + if not await backend.is_available(): + pytest.skip('Docker is required for the real Box probe') + runtime = BoxRuntime(logger=ap.logger, backends=[backend], max_sessions=1) + ap.instance_config.data['box'].update( + { + 'enabled': True, + 'backend': 'docker', + 'local': {'host_root': str(local_agent_e2e_tmpdir / 'box-files'), 'image': 'python:3.12-alpine'}, + } + ) + box = BoxService(ap, client=Client(ap.logger, runtime)) + ap.box_service = box + await box.initialize() + manager = ToolManager(box) + ap.tool_mgr = manager + fake = await _inject_fake_llm_model(ap) + context = await ap.plugin_connector._current_execution_context() + try: + for index in range(2): + fake.queue_llm_responses(_scripted_tool_call('exec'), 'File is ready.') + event = _event( + event_id=f'box-event-{index}', + conversation_id=f'box-conversation-{index}', + text='Copy this attachment into the outbox.', + ) + event.input.attachments = [ + InputAttachment( + type='file', name='input.txt', content=base64.b64encode(f'run-{index}'.encode()).decode() + ) + ] + binding = _binding( + binding_id=f'box-binding-{index}', + allowed_tool_names=['exec'], + runner_config={'box-enabled': True, 'box-session-id-template': '{global}'}, + ) + binding.processor_type = 'pipeline' + messages = await _run_runner(ap, event, binding) + output = [component for message in messages for component in (message.attachments or [])] + assert len(output) == 1, messages + assert base64.b64decode(output[0].base64) == f'run-{index}'.encode() + status = await RunnerBoxService(box).status(context) + assert status['used'] == 1 and status['remaining'] == 0, status + assert manager.bindings[0][0] == manager.bindings[1][0] + assert manager.bindings[0][1] != manager.bindings[1][1] + from langbot_plugin.box.errors import BoxCapacityExceededError + + with pytest.raises(BoxCapacityExceededError): + await RunnerBoxService(box).acquire(context, {'reuse_key': 'different'}) + finally: + await box.shutdown() + + _run_local_agent_probe(local_agent_e2e_tmpdir, probe) diff --git a/tests/unit_tests/agent/test_execution_context.py b/tests/unit_tests/agent/test_execution_context.py index aaaf761cf..8910b5c26 100644 --- a/tests/unit_tests/agent/test_execution_context.py +++ b/tests/unit_tests/agent/test_execution_context.py @@ -2,7 +2,6 @@ from __future__ import annotations -import json from langbot_plugin.api.entities.builtin.runner.delivery import DeliveryContext from langbot_plugin.api.entities.builtin.runner.input import AgentInput @@ -12,13 +11,10 @@ from langbot_plugin.api.entities.builtin.provider.message import ContentElement from langbot.pkg.agent.runner.execution_context import ( append_mcp_resource_context_to_event, build_execution_query, - build_host_box_scope, - prepare_box_scope, prepare_execution_query, project_mcp_resource_config, ) from langbot.pkg.agent.runner.host_models import AgentEventEnvelope -from langbot.pkg.utils import constants class PlatformAdapter: @@ -54,79 +50,14 @@ def make_event( ) -def test_pipeline_and_event_execution_use_same_platform_session_scope(monkeypatch): - monkeypatch.setattr(constants, 'instance_id', 'instance-1') +def test_query_preparation_does_not_choose_a_box(): event = make_event() - query = pipeline_query.Query.model_construct( - query_id=1, - launcher_type='person', - launcher_id='user-1', - sender_id='user-1', - adapter=PlatformAdapter(), - variables={}, - ) - + query = pipeline_query.Query.model_construct(variables={}) prepare_execution_query(query, event, ['pdf']) event_query = build_execution_query(event, ['pdf']) - - assert query.variables['_host_box_scope'] == event_query.variables['_host_box_scope'] - assert query.variables['_pipeline_bound_skills'] == ['pdf'] - assert event_query.variables['_pipeline_bound_skills'] == ['pdf'] - scope = json.loads(query.variables['_host_box_scope']) - assert scope == { - 'instance_id': 'instance-1', - 'workspace_id': 'workspace-1', - 'bot_id': 'bot-1', - 'platform_adapter': 'PlatformAdapter', - 'target_type': 'person', - 'target_id': 'user-1', - 'thread_id': None, - } - - -def test_prepare_box_scope_does_not_change_existing_skill_projection(): - event = make_event() - query = pipeline_query.Query.model_construct( - query_id=1, - launcher_type='person', - launcher_id='user-1', - variables={'_pipeline_bound_skills': ['existing']}, - ) - - variables = prepare_box_scope(query, event) - - assert variables['_host_box_scope'] - assert variables['_pipeline_bound_skills'] == ['existing'] - - -def test_prepare_box_scope_preserves_event_first_channel_scope(): - channel_event = make_event(target_type='channel', target_id='same') - channel_query = build_execution_query(channel_event, []) - original_scope = channel_query.variables['_host_box_scope'] - - prepare_execution_query(channel_query, channel_event, []) - - person_scope = build_execution_query(make_event(target_type='person', target_id='same'), []).variables[ - '_host_box_scope' - ] - assert channel_query.variables['_host_box_scope'] == original_scope - assert json.loads(original_scope)['target_type'] == 'channel' - assert original_scope != person_scope - - -def test_prepare_box_scope_overwrites_untrusted_existing_scope(): - event = make_event(target_type='person', target_id='user-1') - query = pipeline_query.Query.model_construct( - query_id=1, - launcher_type='person', - launcher_id='user-1', - variables={'_host_box_scope': 'forged-scope'}, - ) - - variables = prepare_box_scope(query, event) - - assert variables['_host_box_scope'] != 'forged-scope' - assert json.loads(variables['_host_box_scope'])['target_id'] == 'user-1' + assert query.variables == {'_pipeline_bound_skills': ['pdf']} + assert event_query.variables == {'_pipeline_bound_skills': ['pdf']} + assert getattr(query, '_box_binding', None) is None def test_project_mcp_resource_config_uses_independent_runner_settings(): @@ -187,10 +118,7 @@ def test_event_reply_target_populates_valid_session_identity(): assert query.sender_id == 'room-1' assert query.session.launcher_type.value == 'group' assert query.session.launcher_id == 'room-1' - scope = json.loads(query.variables['_host_box_scope']) - assert scope['target_type'] == 'group' - assert scope['target_id'] == 'room-1' - assert 'rotating-transcript-id' not in query.variables['_host_box_scope'] + assert '_host_box_scope' not in query.variables def test_non_message_event_without_conversation_uses_event_scope(): @@ -205,21 +133,6 @@ def test_non_message_event_without_conversation_uses_event_scope(): query = build_execution_query(event, []) - scope = json.loads(query.variables['_host_box_scope']) - assert scope['target_type'] == 'event' - assert scope['target_id'] == event.event_id + assert '_host_box_scope' not in query.variables assert query.pipeline_config is None assert query.pipeline_uuid is None - - -def test_scope_isolated_by_instance_and_platform_adapter(monkeypatch): - event = make_event(adapter='AdapterA') - monkeypatch.setattr(constants, 'instance_id', 'instance-a') - first = build_host_box_scope(event) - - monkeypatch.setattr(constants, 'instance_id', 'instance-b') - second = build_host_box_scope(event) - other_adapter = build_host_box_scope(make_event(adapter='AdapterB')) - - assert first != second - assert second != other_adapter diff --git a/tests/unit_tests/agent/test_orchestrator_integration.py b/tests/unit_tests/agent/test_orchestrator_integration.py index d634d9151..1c5f2ea66 100644 --- a/tests/unit_tests/agent/test_orchestrator_integration.py +++ b/tests/unit_tests/agent/test_orchestrator_integration.py @@ -927,7 +927,7 @@ class TestQueryEntrySessionQueryId: """Tests for internal query_id entering session registry.""" @pytest.mark.asyncio - async def test_query_box_scope_exists_before_attachment_materialization(self, clean_agent_state): + async def test_query_entry_does_not_select_box_or_materialize_attachments(self, clean_agent_state): """Inbound staging and later runner tools resolve to the same Box session.""" from langbot.pkg.box.service import BoxService @@ -965,9 +965,9 @@ class TestQueryEntrySessionQueryId: session = plugin_connector.sessions_during_run[0] assert session is not None assert session['execution_query'] is query - runner_session_id = box_service.resolver.resolve_box_session_id(session['execution_query']) - assert box_service.materialize_session_id == runner_session_id - assert query.variables['_host_box_scope'] + assert box_service.materialize_session_id is None + assert '_host_box_scope' not in query.variables + assert getattr(query, '_box_binding', None) is None @pytest.mark.asyncio async def test_query_id_registered_in_session_for_query_entry_flow(self, clean_agent_state): @@ -1112,7 +1112,7 @@ class TestQueryEntrySessionQueryId: assert execution_query.sender_id == event.conversation_id assert execution_query.session.launcher_id == event.conversation_id assert execution_query.message_event.type == event.event_type - assert execution_query.variables['_host_box_scope'] + assert '_host_box_scope' not in execution_query.variables assert execution_query.variables['_pipeline_bound_skills'] == ['demo', 'hidden'] assert execution_query.variables['_pipeline_mcp_resource_attachments'][0]['server_uuid'] == 'srv-1' assert execution_query.variables['_pipeline_mcp_resource_agent_read_enabled'] is True @@ -1543,3 +1543,28 @@ async def test_beta_diagnostics_real_runner_terminal(clean_agent_state, terminal import json assert 'CANARY' not in json.dumps(ap.diagnostics.pending) + + +@pytest.mark.asyncio +async def test_agent_cannot_deliver_exported_files_without_reply_api(clean_agent_state): + """Returning file handles must not bypass the Agent's reply authorization.""" + connector = FakePluginConnector( + results=[ + { + 'type': 'message.completed', + 'data': {'message': {'role': 'assistant', 'content': 'file'}, 'file_ids': ['exported-file']}, + } + ] + ) + orchestrator = AgentRunOrchestrator(FakeApplication(connector, clean_agent_state), FakeRegistry(make_descriptor())) + query = make_query() + plan = orchestrator.query_bridge.build_plan(query) + plan.binding.processor_type = 'agent' + with pytest.raises(ValueError, match='sent explicitly'): + [ + m + async for m in orchestrator.run( + plan.event, plan.binding, adapter_context={'_execution_context': TEST_CONTEXT, '_query': query} + ) + ] + assert await get_session_registry().list_active_runs() == [] diff --git a/tests/unit_tests/api/service/test_pipeline_migration.py b/tests/unit_tests/api/service/test_pipeline_migration.py index b9f3080b6..ba41e092d 100644 --- a/tests/unit_tests/api/service/test_pipeline_migration.py +++ b/tests/unit_tests/api/service/test_pipeline_migration.py @@ -732,3 +732,135 @@ async def test_cancel_during_prepare_keeps_original_and_stops_batch(env): ) env.ap.pipeline_mgr.prepare_pipeline.assert_awaited_once() env.ap.pipeline_mgr.publish_pipeline.assert_not_called() + + +async def execute_all(env, install_plugins=True): + response = await env.svc.execute(context(), {'confirmed': True, 'all': True, 'install_plugins': install_plugins}) + task = env.ap.task_mgr.get_task_by_id(response['task_id']) + await task.task + return response, task.task_context.metadata + + +@pytest.mark.asyncio +async def test_all_mode_migrates_workspace_and_skips_completed_on_retry(env): + response, metadata = await execute_all(env) + assert response['pipeline_uuids'] == ['one', 'two'] + assert [r['state'] for r in metadata['results']] == ['migrated', 'migrated'] + configs, backups = await rows(env) + assert configs['foreign'] == SOURCE + assert len(backups) == 2 + with pytest.raises(env.m.MigrationError, match='nothing_to_migrate'): + await execute_all(env) + + +@pytest.mark.asyncio +async def test_all_data_only_without_plugins_never_contacts_runtime_or_marketplace(env): + async with env.engine.begin() as conn: + await conn.execute(sa.delete(PluginSetting)) + env.ap.runner_registry.list_runners.side_effect = AssertionError('offline must not query runtime') + env.ap.plugin_connector.install_plugin = AsyncMock(side_effect=AssertionError('offline must not install')) + _, metadata = await execute_all(env, install_plugins=False) + assert [r['state'] for r in metadata['results']] == ['migrated', 'migrated'] + assert all(r['code'] == 'data_only' for r in metadata['results']) + configs, backups = await rows(env) + assert len(backups) == 2 + assert configs['one']['ai']['runner']['id'] == RID + assert configs['foreign'] == SOURCE + env.ap.plugin_connector.install_plugin.assert_not_awaited() + env.ap.runner_registry.list_runners.assert_not_awaited() + assert env.ap.pipeline_mgr.publish_pipeline.call_count == 2 + + +@pytest.mark.asyncio +async def test_all_mode_installs_missing_plugin_once_then_migrates_both(env): + async with env.engine.begin() as conn: + await conn.execute(sa.delete(PluginSetting)) + + async def install(source, info, task_context): + assert info['plugin_version'] == '1.0' + async with env.engine.begin() as conn: + await conn.execute( + sa.insert(PluginSetting).values( + workspace_uuid=WS, plugin_author='langbot-team', plugin_name='TestAgent', enabled=True + ) + ) + + env.ap.plugin_connector.install_plugin = AsyncMock(side_effect=install) + _, metadata = await execute_all(env) + assert [r['state'] for r in metadata['results']] == ['migrated', 'migrated'] + env.ap.plugin_connector.install_plugin.assert_awaited_once() + + +@pytest.mark.asyncio +async def test_all_install_failure_preserves_sources_and_never_exposes_upstream_error(env): + async with env.engine.begin() as conn: + await conn.execute(sa.delete(PluginSetting)) + env.ap.plugin_connector.install_plugin = AsyncMock(side_effect=RuntimeError('secret-token')) + _, metadata = await execute_all(env) + assert all(r['code'] == 'plugin_install_failed' for r in metadata['results']) + assert 'secret-token' not in str(metadata) + configs, backups = await rows(env) + assert configs['one'] == SOURCE and not backups + env.ap.plugin_connector.install_plugin.assert_awaited_once() + + +@pytest.mark.asyncio +async def test_all_installation_cannot_migrate_edits_made_while_installing(env): + async with env.engine.begin() as conn: + await conn.execute(sa.delete(PluginSetting)) + + async def install(*args, **kwargs): + async with env.engine.begin() as conn: + await conn.execute( + sa.insert(PluginSetting).values( + workspace_uuid=WS, plugin_author='langbot-team', plugin_name='TestAgent', enabled=True + ) + ) + await conn.execute(sa.update(LegacyPipeline).where(LegacyPipeline.uuid == 'one').values(name='edited')) + + env.ap.plugin_connector.install_plugin = AsyncMock(side_effect=install) + _, metadata = await execute_all(env) + assert metadata['results'][0]['code'] == 'preview_stale' + assert metadata['results'][1]['state'] == 'migrated' + configs, backups = await rows(env) + assert configs['one'] == SOURCE + assert len(backups) == 1 + + +@pytest.mark.asyncio +async def test_all_mode_has_no_fifty_pipeline_limit(env): + async with env.engine.begin() as conn: + await conn.execute( + sa.insert(LegacyPipeline), + [ + dict( + uuid=f'extra-{i}', + workspace_uuid=WS, + name=f'extra-{i}', + description='bulk fixture', + for_version='4.10', + stages=[], + config=SOURCE, + extensions_preferences={'enable_all_plugins': True}, + ) + for i in range(51) + ], + ) + response, metadata = await execute_all(env, install_plugins=False) + assert len(response['pipeline_uuids']) == 53 + assert all(r['state'] == 'migrated' for r in metadata['results']) + + +@pytest.mark.asyncio +async def test_all_mode_rejects_duplicate_tasks_and_unconfirmed_requests(env): + for body in [ + {'all': True, 'confirmed': False, 'install_plugins': True}, + {'all': True, 'confirmed': True, 'install_plugins': 'false'}, + {'all': True, 'confirmed': True, 'install_plugins': False, 'workspace_uuid': OTHER}, + ]: + with pytest.raises(env.m.MigrationError): + await env.svc.execute(context(), body) + env.svc._all_tasks.add(WS) + with pytest.raises(env.m.MigrationError, match='migration_running'): + await execute_all(env) + env.svc._all_tasks.clear() diff --git a/tests/unit_tests/box/test_box_service.py b/tests/unit_tests/box/test_box_service.py index 223591a86..0762d2bd0 100644 --- a/tests/unit_tests/box/test_box_service.py +++ b/tests/unit_tests/box/test_box_service.py @@ -41,6 +41,7 @@ from langbot_plugin.box.security import ( from langbot_plugin.entities.io.context import ActionContext from langbot.pkg.api.http.context import ExecutionContext from langbot.pkg.box.service import BoxService +from langbot.pkg.box.runner import RunBoxBinding _UTC = dt.timezone.utc _CONTEXT = ExecutionContext( @@ -173,7 +174,7 @@ class FakeBackend(BaseSandboxBackend): def make_query(query_id: int = 42) -> pipeline_query.Query: - return pipeline_query.Query.model_construct( + query = pipeline_query.Query.model_construct( query_id=query_id, query_uuid=f'query-{query_id}', instance_uuid=_CONTEXT.instance_uuid, @@ -192,6 +193,11 @@ def make_query(query_id: int = 42) -> pipeline_query.Query: }, ) + object.__setattr__( + query, '_box_binding', RunBoxBinding(f'run-{query_id}', 'person_test_user', {}, f'query-{query_id}') + ) + return query + def make_app( logger: Mock, @@ -637,129 +643,6 @@ async def test_box_service_defaults_session_id_from_query(): assert backend.start_calls == ['person_test_user'] -@pytest.mark.asyncio -async def test_box_service_session_id_uses_query_attributes_without_variables(): - logger = Mock() - backend = FakeBackend(logger) - runtime = BoxRuntime(logger=logger, backends=[backend], session_ttl_sec=300) - service = BoxService(make_app(logger), client=_InProcessBoxRuntimeClient(logger, runtime)) - await service.initialize() - - query = pipeline_query.Query.model_construct( - query_id=7, - instance_uuid=_CONTEXT.instance_uuid, - workspace_uuid=_CONTEXT.workspace_uuid, - placement_generation=_CONTEXT.placement_generation, - launcher_type='group', - launcher_id='room-1', - ) - result = await service.execute_tool({'command': 'pwd'}, query) - - assert result['session_id'] == 'group_room-1' - assert result['ok'] is True - assert backend.start_calls == ['group_room-1'] - - -@pytest.mark.asyncio -async def test_box_service_session_id_falls_back_to_query_id_for_synthetic_queries(): - logger = Mock() - backend = FakeBackend(logger) - runtime = BoxRuntime(logger=logger, backends=[backend], session_ttl_sec=300) - service = BoxService(make_app(logger), client=_InProcessBoxRuntimeClient(logger, runtime)) - await service.initialize() - - query = pipeline_query.Query.model_construct( - query_id=7, - instance_uuid=_CONTEXT.instance_uuid, - workspace_uuid=_CONTEXT.workspace_uuid, - placement_generation=_CONTEXT.placement_generation, - ) - result = await service.execute_tool({'command': 'pwd'}, query) - - assert result['session_id'] == 'query_7' - assert result['ok'] is True - assert backend.start_calls == ['query_7'] - - -@pytest.mark.asyncio -async def test_box_service_forced_global_scope_overrides_pipeline_template(): - """SaaS guard: a non-empty ``force_box_session_id_template`` pins every - query to one shared sandbox regardless of the pipeline's own scope.""" - logger = Mock() - backend = FakeBackend(logger) - runtime = BoxRuntime(logger=logger, backends=[backend], session_ttl_sec=300) - service = BoxService( - make_app(logger, force_box_session_id_template='{global}'), - client=_InProcessBoxRuntimeClient(logger, runtime), - ) - await service.initialize() - - # Two distinct callers that would otherwise get separate sandboxes. - q1 = pipeline_query.Query.model_construct( - query_id=1, - instance_uuid=_CONTEXT.instance_uuid, - workspace_uuid=_CONTEXT.workspace_uuid, - placement_generation=_CONTEXT.placement_generation, - launcher_type='group', - launcher_id='room-1', - ) - q2 = pipeline_query.Query.model_construct( - query_id=2, - instance_uuid=_CONTEXT.instance_uuid, - workspace_uuid=_CONTEXT.workspace_uuid, - placement_generation=_CONTEXT.placement_generation, - launcher_type='person', - launcher_id='alice', - ) - - r1 = await service.execute_tool({'command': 'pwd'}, q1) - r2 = await service.execute_tool({'command': 'pwd'}, q2) - - assert r1['session_id'] == 'global' - assert r2['session_id'] == 'global' - # Only one sandbox was ever started — the shared global one. - assert backend.start_calls == ['global'] - - -def test_box_service_forced_template_ignores_pipeline_config(): - """The forced template wins even when the pipeline explicitly sets a - per-user scope — proving the override is not bypassable via pipeline config.""" - logger = Mock() - service = BoxService( - make_app(logger, force_box_session_id_template='{global}'), - client=Mock(spec=BoxRuntimeClient), - ) - query = pipeline_query.Query.model_construct( - query_id=7, - launcher_type='person', - launcher_id='test_user', - sender_id='test_user', - pipeline_config={ - 'ai': {'local-agent': {'box-session-id-template': '{launcher_type}_{launcher_id}_{sender_id}'}} - }, - ) - - assert service.resolve_box_session_id(query) == 'global' - - -def test_box_service_empty_forced_template_respects_pipeline_config(): - """An empty/whitespace forced template is a no-op: the pipeline's own - scope template is honoured (default non-SaaS behaviour).""" - logger = Mock() - service = BoxService( - make_app(logger, force_box_session_id_template=' '), - client=Mock(spec=BoxRuntimeClient), - ) - query = pipeline_query.Query.model_construct( - query_id=7, - launcher_type='group', - launcher_id='room-1', - pipeline_config={'ai': {'local-agent': {'box-session-id-template': '{launcher_type}_{launcher_id}'}}}, - ) - - assert service.resolve_box_session_id(query) == 'group_room-1' - - @pytest.mark.asyncio async def test_box_service_fails_closed_when_backend_unavailable(): logger = Mock() @@ -793,7 +676,7 @@ async def test_box_service_allows_host_mount_under_configured_root(tmp_path): ) assert result['ok'] is True - assert backend.start_calls == ['11'] + assert backend.start_calls == ['person_test_user'] @pytest.mark.asyncio @@ -875,41 +758,6 @@ async def test_box_service_rejects_host_mount_outside_allowed_roots(tmp_path): ) -class TestGetSystemGuidance: - """``get_system_guidance`` must ALWAYS advertise the per-query outbox path - when given a ``query_id`` — even with no inbound attachment — so files the - agent generates (QR codes, charts, rendered docs) are actually delivered. - - The wrapper collects the outbox on every turn regardless of inbound files; - before this, the agent was only told the outbox path inside the - inbound-attachment note, so pure-generation turns produced files that were - silently dropped. - """ - - def _service(self, logger=None): - logger = logger or Mock() - runtime = BoxRuntime(logger=logger, backends=[FakeBackend(logger)], session_ttl_sec=300) - return BoxService(make_app(logger), client=_InProcessBoxRuntimeClient(logger, runtime)) - - def test_guidance_includes_outbox_when_query_id_given(self): - service = self._service() - guidance = service.get_system_guidance(42) - assert f'{service.OUTBOX_MOUNT_DIR}/42' in guidance - assert 'delivered to the user automatically' in guidance - - def test_guidance_omits_outbox_without_query_id(self): - service = self._service() - guidance = service.get_system_guidance() - assert service.OUTBOX_MOUNT_DIR not in guidance - # core exec guidance is still present - assert 'exec tool' in guidance - - def test_guidance_outbox_independent_of_inbound_attachments(self): - # A bare query_id (the pure-generation case) still gets the outbox note. - service = self._service() - assert f'{service.OUTBOX_MOUNT_DIR}/0' in service.get_system_guidance(0) - - @pytest.mark.asyncio async def test_box_runtime_rejects_host_mount_conflict_in_same_session(tmp_path): logger = Mock() @@ -2427,14 +2275,16 @@ class TestAttachmentHostPath: service, _ws = self._service_with_workspace(tmp_path) first = make_query(query_id=7) second = make_query(query_id=7) - object.__setattr__(first, 'query_uuid', 'replica-a-query') - object.__setattr__(second, 'query_uuid', 'replica-b-query') + first._box_binding.io_scope = 'replica-a-run' + object.__setattr__(first, 'query_uuid', 'same-query') + second._box_binding.io_scope = 'replica-b-run' + object.__setattr__(second, 'query_uuid', 'same-query') first_path = service._host_query_dir(service.OUTBOX_SUBDIR, first) second_path = service._host_query_dir(service.OUTBOX_SUBDIR, second) - assert first_path is not None and first_path.endswith('/outbox/replica-a-query') - assert second_path is not None and second_path.endswith('/outbox/replica-b-query') + assert first_path is not None and first_path.endswith('/outbox/replica-a-run') + assert second_path is not None and second_path.endswith('/outbox/replica-b-run') assert first_path != second_path @pytest.mark.asyncio diff --git a/tests/unit_tests/box/test_runner_box.py b/tests/unit_tests/box/test_runner_box.py new file mode 100644 index 000000000..ad49482f5 --- /dev/null +++ b/tests/unit_tests/box/test_runner_box.py @@ -0,0 +1,182 @@ +from types import SimpleNamespace +from unittest.mock import AsyncMock +import asyncio +import base64 + +import pytest +from langbot_plugin.api.entities.builtin.platform.message import File, MessageChain +from langbot_plugin.box.errors import BoxValidationError +from langbot.pkg.box.runner import RunnerBoxService, RunBoxBinding, exported_message + + +def service(): + sessions = {} + + async def create(context, spec): + sessions.setdefault(spec['session_id'], spec) + return sessions[spec['session_id']] + + box = SimpleNamespace( + enabled=True, + managed_admission_required=False, + _ATTACHMENT_MAX_TOTAL_BYTES=1000000, + create_session=AsyncMock(side_effect=create), + require_workspace_sandbox=AsyncMock(side_effect=lambda context: context), + _action_context=lambda context: context, + client=SimpleNamespace(get_sessions=AsyncMock(side_effect=lambda **kw: list(sessions.values()))), + build_skill_extra_mounts=lambda query: [], + ) + return RunnerBoxService(box) + + +@pytest.mark.asyncio +async def test_acquire_reuses_key_and_bind_is_explicit_and_run_scoped(): + api = service() + first, second = await asyncio.gather( + api.acquire('workspace', {'reuse_key': 'global'}), + api.acquire('workspace', {'reuse_key': 'global'}), + ) + assert first == second + q1, q2 = SimpleNamespace(), SimpleNamespace() + a = await api.bind('workspace', q1, 'run-a', first['id']) + b = await api.bind('workspace', q2, 'run-b', first['id']) + assert a['box_id'] == b['box_id'] + assert a['outbox'] != b['outbox'] + different = await api.acquire('workspace', {'reuse_key': 'other'}) + with pytest.raises(BoxValidationError, match='cannot change'): + await api.bind('workspace', q1, 'run-a', different['id']) + with pytest.raises(BoxValidationError, match='not found'): + await api.bind('workspace', SimpleNamespace(), 'run-c', 'foreign-box') + + +@pytest.mark.asyncio +async def test_concurrent_binding_cannot_switch_boxes(): + api = service() + a = await api.acquire('ws', {'reuse_key': 'a'}) + b = await api.acquire('ws', {'reuse_key': 'b'}) + q = SimpleNamespace() + results = await asyncio.gather( + api.bind('ws', q, 'run', a['id']), api.bind('ws', q, 'run', b['id']), return_exceptions=True + ) + assert sum(isinstance(r, BoxValidationError) for r in results) == 1 + + +@pytest.mark.asyncio +async def test_plugin_cannot_supply_mount_or_limit_overrides(): + api = service() + for field in ('host_path', 'extra_mounts', 'max_sessions', 'memory_mb', 'session_id'): + with pytest.raises(BoxValidationError): + await api.acquire('ws', {'reuse_key': 'global', 'options': {field: '/etc'}}) + api.box.create_session.assert_not_called() + api.box.managed_admission_required = True + with pytest.raises(BoxValidationError, match='global'): + await api.acquire('ws', {'reuse_key': 'other'}) + + +@pytest.mark.asyncio +async def test_selective_import_is_idempotent_and_separates_duplicate_names(): + api = service() + q = SimpleNamespace( + message_chain=MessageChain( + [ + File(name='same.txt', base64=base64.b64encode(b'a').decode()), + File(name='same.txt', base64=base64.b64encode(b'b').decode()), + ] + ), + _box_binding=RunBoxBinding('run', 'box', {}, 'run'), + ) + + async def materialize(query): + return [ + { + 'name': 'same.txt', + 'type': 'File', + 'size': 1, + 'path': f'/workspace/inbox/{query._box_binding.io_scope}/same.txt', + } + ] + + api.box.materialize_inbound_attachments = AsyncMock(side_effect=materialize) + a = await api.import_attachments(q, ['attachment-1']) + b = await api.import_attachments(q, None) + assert a['items'][0] == b['items'][1] + assert b['items'][0]['path'] != b['items'][1]['path'] + assert api.box.materialize_inbound_attachments.await_count == 2 + with pytest.raises(BoxValidationError, match='Unknown'): + await api.import_attachments(q, ['foreign-file']) + + +@pytest.mark.asyncio +async def test_export_does_not_send_and_references_cannot_cross_runs_or_repeat(): + api = service() + q = SimpleNamespace(_box_binding=RunBoxBinding('run', 'box', {}, 'run')) + api.box.collect_outbound_attachments = AsyncMock( + return_value=[ + {'name': 'answer.txt', 'type': 'File', 'base64': base64.b64encode(b'answer').decode()}, + ] + ) + result = await api.export_files(q) + file = result['items'][0] + assert 'base64' not in file and file['size'] == 6 + other = SimpleNamespace(_box_binding=RunBoxBinding('other-run', 'box', {}, 'other-run')) + with pytest.raises(BoxValidationError): + exported_message(other, [file['id']]) + chain = exported_message(q, [file['id']], consume=True) + assert chain[0].name == 'answer.txt' + with pytest.raises(BoxValidationError, match='already'): + exported_message(q, [file['id']]) + + +@pytest.mark.asyncio +async def test_status_preserves_unknown_capacity_and_connection_failure(): + api = service() + api.box.get_status = AsyncMock(return_value={'available': False, 'connector_error': 'offline'}) + result = await api.status('ws') + assert result['remaining'] is None and result['limit'] is None + assert result['available'] is False + + +def test_event_attachments_preserved_without_eager_io_or_host_path_access(): + from langbot_plugin.api.entities.builtin.runner.input import AgentInput, InputAttachment + from langbot.pkg.box.runner import prepare_input_files, input_files + from langbot_plugin.api.entities.builtin.provider.message import ContentElement + + q = SimpleNamespace(message_chain=MessageChain([])) + value = AgentInput(attachments=[InputAttachment(type='file', name='a.txt', content='YQ==', path='/etc/passwd')]) + prepare_input_files(q, value) + assert value.attachments[0].content == 'YQ==' + assert value.attachments[0].ref == 'attachment-0' + assert value.attachments[0].path is None + assert input_files(q)[0].base64 == 'YQ==' + assert not input_files(q)[0].path + image = AgentInput(contents=[ContentElement.from_image_url('https://example.invalid/image.png')]) + prepare_input_files(SimpleNamespace(message_chain=MessageChain([])), image) + assert image.attachments[0].url == 'https://example.invalid/image.png' + + +def test_attachment_references_follow_metadata_not_platform_list_order(): + from langbot_plugin.api.entities.builtin.runner.input import AgentInput, InputAttachment + from langbot.pkg.box.runner import prepare_input_files, input_files + from langbot_plugin.api.entities.builtin.platform.message import Image + + a, b = File(name='a', base64='YQ=='), Image(url='https://example.invalid/b') + q = SimpleNamespace(message_chain=MessageChain([a, b])) + value = AgentInput( + attachments=[InputAttachment(type='image', url=b.url), InputAttachment(type='file', name='a', content='YQ==')] + ) + prepare_input_files(q, value) + assert input_files(q) == [b, a] + + +@pytest.mark.asyncio +async def test_image_export_accepts_data_url_and_enforces_total_bytes(): + api = service() + q = SimpleNamespace(_box_binding=RunBoxBinding('run', 'box', {}, 'run')) + api.box.collect_outbound_attachments = AsyncMock( + return_value=[{'name': 'a.png', 'type': 'Image', 'base64': 'data:image/png;base64,YQ=='}] + ) + result = await api.export_files(q) + assert result['items'][0]['size'] == 1 + api.box._ATTACHMENT_MAX_TOTAL_BYTES = 1 + with pytest.raises(BoxValidationError, match='byte limit'): + await api.export_files(q) diff --git a/tests/unit_tests/pipeline/test_legacy_config_migration.py b/tests/unit_tests/pipeline/test_legacy_config_migration.py index 5f4319dfc..dccbc3adb 100644 --- a/tests/unit_tests/pipeline/test_legacy_config_migration.py +++ b/tests/unit_tests/pipeline/test_legacy_config_migration.py @@ -11,7 +11,7 @@ import pytest FIXTURES = json.loads((Path(__file__).parents[2] / 'fixtures/pipeline_migration/synthetic_legacy.json').read_text()) TARGETS = { - 'local-agent': ('LocalAgent', '0.1.6', None), + 'local-agent': ('LocalAgent', '0.1.7', None), 'dify-service-api': ('DifyAgent', '0.1.7', None), 'coze-api': ('CozeAgent', '0.1.7', None), 'dashscope-app-api': ('DashScopeAgent', '0.1.7', None), @@ -411,7 +411,6 @@ def test_local_rounds_are_never_translated_into_transcript_item_counts(rounds): ('prompt', [{'role': 'system', 'content': 42}], 'local.prompt_shape'), ('prompt', [{'role': 'system', 'content': 'text', 'SECRET-key': 'SECRET-value'}], 'local.prompt_shape'), ('prompt', [{'role': 'system', 'content': [{'type': 'text', 'text': 42}]}], 'local.prompt_shape'), - ('box-session-id-template', '{global}', 'local.box_scope'), ], ) def test_local_unsupported_behaviors_have_specific_safe_blockers(field, value, code): @@ -980,7 +979,16 @@ def test_default_local_agent_blocks_instead_of_inventing_round_translation(): assert planner().PLANNER_VERSION == '3' assert result['state'] == 'blocked' assert result['target_runner_id'] == 'plugin:langbot-team/LocalAgent/default' - assert result['target_plugin'] == {'author': 'langbot-team', 'name': 'LocalAgent', 'version': '0.1.6'} + assert result['target_plugin'] == {'author': 'langbot-team', 'name': 'LocalAgent', 'version': '0.1.7'} assert {'code': 'missing_field', 'field': 'ai.local-agent.prompt'} in result['blockers'] assert result['config'] is None assert source == original + + +@pytest.mark.parametrize('template', ['{global}', '{launcher_type}_{launcher_id}', '{sender_id}', '{project}']) +def test_local_box_reuse_templates_are_preserved_for_plugin(template): + source = source_for('local-agent') + source['ai']['local-agent']['box-session-id-template'] = template + result = plan(source) + assert result['state'] != 'blocked', result + assert result['config']['ai']['runner_config'][result['target_runner_id']]['box-session-id-template'] == template diff --git a/tests/unit_tests/pipeline/test_migration_current_shape.py b/tests/unit_tests/pipeline/test_migration_current_shape.py index 8656c156c..7d976ed5f 100644 --- a/tests/unit_tests/pipeline/test_migration_current_shape.py +++ b/tests/unit_tests/pipeline/test_migration_current_shape.py @@ -48,20 +48,18 @@ def test_sdk_valid_omitted_prompt_content_is_preserved_exactly(): assert 'content' not in migrated[0] -@pytest.mark.parametrize('template', ['', '{launcher_type}_{launcher_id}']) -def test_standard_box_template_is_explicit_reset_not_custom_scope_block(template): +@pytest.mark.parametrize('template', ['{global}', '{launcher_type}_{launcher_id}', '{launcher_id}', '{workspace}']) +def test_box_reuse_template_is_preserved_for_runner(template): source = {'ai': {'runner': {'runner': 'local-agent'}, 'local-agent': copy.deepcopy(FIXTURES['local-agent'])}} source['ai']['local-agent']['box-session-id-template'] = template result = plan_legacy_pipeline(source) assert result['state'] == 'ready', result['blockers'] - assert {'code': 'local.box_state_reset', 'field': 'ai.local-agent.box-session-id-template'} in result['warnings'] - assert 'box-session-id-template' not in result['config']['ai']['runner_config'][result['target_runner_id']] + assert result['config']['ai']['runner_config'][result['target_runner_id']]['box-session-id-template'] == template -@pytest.mark.parametrize('template', ['global', '{launcher_id}', '{workspace}', ' {launcher_type}_{launcher_id}']) -def test_custom_box_sharing_stays_blocked(template): +def test_empty_box_template_requires_correction(): source = {'ai': {'runner': {'runner': 'local-agent'}, 'local-agent': copy.deepcopy(FIXTURES['local-agent'])}} - source['ai']['local-agent']['box-session-id-template'] = template + source['ai']['local-agent']['box-session-id-template'] = '' result = plan_legacy_pipeline(source) assert result['state'] == 'blocked' - assert {'code': 'local.box_scope', 'field': 'ai.local-agent.box-session-id-template'} in result['blockers'] + assert {'code': 'invalid_type', 'field': 'ai.local-agent.box-session-id-template'} in result['blockers'] diff --git a/tests/unit_tests/pipeline/test_wrapper.py b/tests/unit_tests/pipeline/test_wrapper.py index 48823e840..1dd9efbb8 100644 --- a/tests/unit_tests/pipeline/test_wrapper.py +++ b/tests/unit_tests/pipeline/test_wrapper.py @@ -340,6 +340,7 @@ class TestResponseWrapperAssistant: assistant_resp = Mock() assistant_resp.role = 'assistant' assistant_resp.content = None + assistant_resp.attachments = None assistant_resp.tool_calls = None query.resp_messages = [assistant_resp] diff --git a/tests/unit_tests/pipeline/test_wrapper_outbound_attachments.py b/tests/unit_tests/pipeline/test_wrapper_outbound_attachments.py index 8fc000bf5..f01d3655b 100644 --- a/tests/unit_tests/pipeline/test_wrapper_outbound_attachments.py +++ b/tests/unit_tests/pipeline/test_wrapper_outbound_attachments.py @@ -1,146 +1,18 @@ -"""Unit tests for ResponseWrapper outbound-attachment helpers. - -Covers the sandbox -> user attachment path added for the Box attachment -round-trip: - -* ``_is_final_assistant_message`` — only the terminal, tool-call-free assistant - message (or a final MessageChunk) should trigger collection. -* ``_append_outbound_attachments`` — collects sandbox outbox files exactly once - per query and maps each descriptor to the right platform component, swallowing - collection errors. -""" - -from __future__ import annotations - -from types import SimpleNamespace -from unittest.mock import AsyncMock, Mock +"""Output attachments are explicitly submitted, never scanned by the wrapper.""" +from langbot_plugin.api.entities.builtin.platform.message import MessageChain, File +from langbot_plugin.api.entities.builtin.provider.message import Message, MessageChunk import pytest -import langbot_plugin.api.entities.builtin.platform.message as platform_message -import langbot_plugin.api.entities.builtin.provider.message as provider_message -from langbot.pkg.pipeline.wrapper.wrapper import ResponseWrapper +@pytest.mark.parametrize('cls', [Message, MessageChunk]) +def test_explicit_output_attachments_survive_platform_conversion_only(cls): + message = cls(role='assistant', content='done', attachments=MessageChain([File(name='result.txt', base64='YQ==')])) + assert message.get_content_platform_message_chain()[-1].name == 'result.txt' + assert 'attachments' not in message.model_dump() -def _make_wrapper(box_service) -> ResponseWrapper: - app = SimpleNamespace(logger=Mock()) - wrapper = ResponseWrapper.__new__(ResponseWrapper) - wrapper.ap = app - return wrapper - - -def _make_query(): - return SimpleNamespace(variables={}) - - -def test_is_final_assistant_message_plain_assistant(): - wrapper = _make_wrapper(box_service=None) - msg = provider_message.Message(role='assistant', content='done') - assert wrapper._is_final_assistant_message(msg) is True - - -def test_is_final_assistant_message_rejects_non_assistant(): - wrapper = _make_wrapper(box_service=None) - msg = provider_message.Message(role='tool', content='{}') - assert wrapper._is_final_assistant_message(msg) is False - - -def test_is_final_assistant_message_rejects_tool_call_round(): - wrapper = _make_wrapper(box_service=None) - msg = provider_message.Message( - role='assistant', - content='calling', - tool_calls=[ - provider_message.ToolCall( - id='c1', - type='function', - function=provider_message.FunctionCall(name='exec', arguments='{}'), - ) - ], - ) - assert wrapper._is_final_assistant_message(msg) is False - - -def test_is_final_assistant_message_non_final_chunk(): - wrapper = _make_wrapper(box_service=None) - chunk = provider_message.MessageChunk(role='assistant', content='partial', is_final=False) - assert wrapper._is_final_assistant_message(chunk) is False - - final_chunk = provider_message.MessageChunk(role='assistant', content='partial', is_final=True) - assert wrapper._is_final_assistant_message(final_chunk) is True - - -@pytest.mark.asyncio -async def test_append_outbound_attachments_maps_each_type(): - box_service = SimpleNamespace( - available=True, - collect_outbound_attachments=AsyncMock( - return_value=[ - {'type': 'Image', 'base64': 'data:image/png;base64,iVBORw0K'}, - {'type': 'Voice', 'base64': 'data:audio/wav;base64,UklGRg=='}, - {'type': 'File', 'name': 'report.xlsx', 'base64': 'data:app;base64,UEsDBA=='}, - ] - ), - ) - wrapper = _make_wrapper(box_service) - wrapper.ap.box_service = box_service - query = _make_query() - chain = platform_message.MessageChain([]) - - await wrapper._append_outbound_attachments(query, chain) - - kinds = [type(c).__name__ for c in chain] - assert kinds == ['Image', 'Voice', 'File'] - assert query.variables['_sandbox_outbound_collected'] is True - # File keeps its name - file_comp = chain[2] - assert getattr(file_comp, 'name', None) == 'report.xlsx' - - -@pytest.mark.asyncio -async def test_append_outbound_attachments_runs_once_per_query(): - box_service = SimpleNamespace( - available=True, - collect_outbound_attachments=AsyncMock(return_value=[]), - ) - wrapper = _make_wrapper(box_service) - wrapper.ap.box_service = box_service - query = _make_query() - query.variables['_sandbox_outbound_collected'] = True - chain = platform_message.MessageChain([]) - - await wrapper._append_outbound_attachments(query, chain) - - box_service.collect_outbound_attachments.assert_not_awaited() - assert len(chain) == 0 - - -@pytest.mark.asyncio -async def test_append_outbound_attachments_noop_without_box_service(): - wrapper = _make_wrapper(box_service=None) - wrapper.ap.box_service = None - query = _make_query() - chain = platform_message.MessageChain([]) - - await wrapper._append_outbound_attachments(query, chain) - assert len(chain) == 0 - # not marked collected, since service is unavailable - assert '_sandbox_outbound_collected' not in query.variables - - -@pytest.mark.asyncio -async def test_append_outbound_attachments_swallows_collection_error(): - box_service = SimpleNamespace( - available=True, - collect_outbound_attachments=AsyncMock(side_effect=RuntimeError('boom')), - ) - wrapper = _make_wrapper(box_service) - wrapper.ap.box_service = box_service - query = _make_query() - chain = platform_message.MessageChain([]) - - # must not raise - await wrapper._append_outbound_attachments(query, chain) - assert len(chain) == 0 - wrapper.ap.logger.warning.assert_called_once() +@pytest.mark.parametrize('cls', [Message, MessageChunk]) +def test_file_only_output(cls): + message = cls(role='assistant', attachments=MessageChain([File(name='result.txt', base64='YQ==')])) + assert len(message.get_content_platform_message_chain()) == 1 diff --git a/tests/unit_tests/plugin/test_connector_reconcile.py b/tests/unit_tests/plugin/test_connector_reconcile.py index c4fac01e2..605bcba88 100644 --- a/tests/unit_tests/plugin/test_connector_reconcile.py +++ b/tests/unit_tests/plugin/test_connector_reconcile.py @@ -740,3 +740,25 @@ async def test_missing_artifact_repair_adds_dependency_failure_and_continues(): ] assert setting_a.installation_uuid in connector._installation_failures assert setting_b.installation_uuid not in connector._installation_failures + + +@pytest.mark.asyncio +async def test_marketplace_download_honors_migration_version_without_latest_lookup(monkeypatch): + import langbot.pkg.plugin.connector as connector_module + + connector = connection_result_connector(AsyncMock()) + download = AsyncMock(return_value=(200, b'pinned-plugin-package')) + monkeypatch.setattr(connector_module, '_marketplace_get', download) + package, version = await connector._download_marketplace_package( + SimpleNamespace(), 'langbot-team', 'LocalAgent', None, version='0.1.6' + ) + assert package == b'pinned-plugin-package' + assert version == '0.1.6' + download.assert_awaited_once() + assert download.call_args.args[1].endswith('/plugins/download/langbot-team/LocalAgent/0.1.6') + for version in ('../latest', '1.0?token=x', '1.0/other'): + with pytest.raises(ValueError, match='Invalid plugin version'): + await connector._download_marketplace_package( + SimpleNamespace(), 'langbot-team', 'LocalAgent', None, version=version + ) + assert download.await_count == 1 diff --git a/tests/unit_tests/plugin/test_handler_actions.py b/tests/unit_tests/plugin/test_handler_actions.py index 595e763a4..105cd6125 100644 --- a/tests/unit_tests/plugin/test_handler_actions.py +++ b/tests/unit_tests/plugin/test_handler_actions.py @@ -1597,6 +1597,9 @@ class TestAgentRunProxyActions: app.tool_mgr = tool_mgr run_id = 'run_pure_event_native_exec' + from langbot.pkg.box.runner import RunBoxBinding + + object.__setattr__(query, '_box_binding', RunBoxBinding(run_id, 'box', {}, run_id)) registry = get_session_registry() await registry.unregister(run_id) await registry.register( diff --git a/tests/unit_tests/provider/test_tool_manager_native.py b/tests/unit_tests/provider/test_tool_manager_native.py index a50e044de..7dd09f32f 100644 --- a/tests/unit_tests/provider/test_tool_manager_native.py +++ b/tests/unit_tests/provider/test_tool_manager_native.py @@ -1,4 +1,5 @@ from __future__ import annotations +from langbot.pkg.box.runner import RunBoxBinding import base64 import contextlib @@ -236,6 +237,7 @@ async def test_native_tool_loader_rechecks_admission_at_the_final_invoke_boundar query_uuid=None, ) + query._box_binding = RunBoxBinding('run', 'box', {}, 'run') with pytest.raises(RuntimeError, match='entitlement expired'): await loader.invoke_tool('read', {'path': '/workspace/private.txt'}, query) @@ -260,6 +262,7 @@ def _make_loader_with_workspace(tmpdir: str) -> tuple[NativeToolLoader, Mock]: def _make_query() -> SimpleNamespace: return SimpleNamespace( + _box_binding=RunBoxBinding('run', 'box', {}, 'run'), query_id='test-query-1', query_uuid='test-query-1', instance_uuid=_CONTEXT.instance_uuid, @@ -510,7 +513,9 @@ async def test_path_escape_blocked(): ], ) @pytest.mark.asyncio -@pytest.mark.skipif(not native_loader._SECURE_HOST_FILE_OPS_AVAILABLE, reason='Requires POSIX descriptor-relative host APIs') +@pytest.mark.skipif( + not native_loader._SECURE_HOST_FILE_OPS_AVAILABLE, reason='Requires POSIX descriptor-relative host APIs' +) async def test_host_workspace_operations_do_not_follow_a_swapped_ancestor( monkeypatch, tool_name: str, diff --git a/uv.lock b/uv.lock index cdf46c2a9..f20c2be6f 100644 --- a/uv.lock +++ b/uv.lock @@ -2180,7 +2180,7 @@ requires-dist = [ { name = "ebooklib", specifier = ">=0.18" }, { name = "gewechat-client", specifier = ">=0.1.5" }, { name = "html2text", specifier = ">=2024.2.26" }, - { name = "langbot-plugin", specifier = "==0.6.0b2" }, + { name = "langbot-plugin", git = "https://github.com/langbot-app/langbot-plugin-sdk?rev=94c098ee0d1c4535043d9bf4a74c25f90849bc41" }, { name = "langchain", specifier = ">=1.3.9" }, { name = "langchain-core", specifier = ">=1.3.3" }, { name = "langchain-text-splitters", specifier = ">=1.1.2" }, @@ -2251,7 +2251,7 @@ dev = [ [[package]] name = "langbot-plugin" version = "0.6.0b2" -source = { registry = "https://pypi.org/simple" } +source = { git = "https://github.com/langbot-app/langbot-plugin-sdk?rev=94c098ee0d1c4535043d9bf4a74c25f90849bc41#94c098ee0d1c4535043d9bf4a74c25f90849bc41" } dependencies = [ { name = "aiofiles" }, { name = "aiohttp" }, @@ -2271,10 +2271,6 @@ dependencies = [ { name = "watchdog" }, { name = "websockets" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/bf/d9/fc410f8b7c754196ca72124ca9a76a41a334114487938137795099130bd9/langbot_plugin-0.6.0b2.tar.gz", hash = "sha256:f895ab6da4e9ab3e1c7dd037b610b5303ed1a9946129b3b776643452a6ec1caf", size = 600560, upload-time = "2026-09-15T09:52:05.516Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/9f/4f/2ad61ed4eeca03f532def1c5385cf0cdc0802b98207ef6efe82b4625238b/langbot_plugin-0.6.0b2-py3-none-any.whl", hash = "sha256:9890f8cfd9089b5fdf54597ea019cd30a426d5ea5f75f0fa378014e7ed52fcbe", size = 400234, upload-time = "2026-09-15T09:52:03.802Z" }, -] [[package]] name = "langchain" diff --git a/web/src/app/home/bots/components/bot-form/PluginProcessorBindings.tsx b/web/src/app/home/bots/components/bot-form/PluginProcessorBindings.tsx index b21b18052..26aed86af 100644 --- a/web/src/app/home/bots/components/bot-form/PluginProcessorBindings.tsx +++ b/web/src/app/home/bots/components/bot-form/PluginProcessorBindings.tsx @@ -231,7 +231,7 @@ export default function PluginProcessorBindings({ return ( ; + } const selectedOption = config.options?.find( (option) => option.name === field.value, ); diff --git a/web/src/app/home/components/dynamic-form/DynamicFormItemConfig.ts b/web/src/app/home/components/dynamic-form/DynamicFormItemConfig.ts index aeac8c989..2fd1ce5de 100644 --- a/web/src/app/home/components/dynamic-form/DynamicFormItemConfig.ts +++ b/web/src/app/home/components/dynamic-form/DynamicFormItemConfig.ts @@ -16,6 +16,7 @@ export class DynamicFormItemConfig implements IDynamicFormItemSchema { type: DynamicFormItemType; description?: I18nObject; options?: IDynamicFormItemOption[]; + allow_custom?: boolean; show_if?: IShowIfCondition; login_platform?: string; url?: string; @@ -32,6 +33,7 @@ export class DynamicFormItemConfig implements IDynamicFormItemSchema { this.type = params.type; this.description = params.description; this.options = params.options; + this.allow_custom = params.allow_custom; this.show_if = params.show_if; this.login_platform = params.login_platform; this.url = params.url; diff --git a/web/src/app/home/components/dynamic-form/PresetSelect.tsx b/web/src/app/home/components/dynamic-form/PresetSelect.tsx new file mode 100644 index 000000000..ff3004607 --- /dev/null +++ b/web/src/app/home/components/dynamic-form/PresetSelect.tsx @@ -0,0 +1,77 @@ +import { useState } from 'react'; +import { ControllerRenderProps } from 'react-hook-form'; +import { useTranslation } from 'react-i18next'; +import { IDynamicFormItemSchema } from '@/app/infra/entities/form/dynamic'; +import { extractI18nObject } from '@/i18n/I18nProvider'; +import { Input } from '@/components/ui/input'; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select'; + +export default function PresetSelect({ + config, + field, +}: { + config: IDynamicFormItemSchema; + field: ControllerRenderProps; +}) { + const { t } = useTranslation(); + const options = config.options?.filter((option) => option.name.trim()) ?? []; + const presetIndex = options.findIndex( + (option) => option.name === field.value, + ); + // Selecting custom keeps the existing template until the user edits it. + const [editingValue, setEditingValue] = useState(null); + const custom = presetIndex < 0 || editingValue === field.value; + + return ( +
+ + {custom && ( + { + setEditingValue(event.target.value); + field.onChange(event.target.value); + }} + /> + )} +
+ ); +} diff --git a/web/src/app/home/pipelines/PipelineMigration.tsx b/web/src/app/home/pipelines/PipelineMigration.tsx index 561bd133d..853d46f5d 100644 --- a/web/src/app/home/pipelines/PipelineMigration.tsx +++ b/web/src/app/home/pipelines/PipelineMigration.tsx @@ -1,19 +1,18 @@ import { useCallback, useEffect, useRef, useState } from 'react'; import { useTranslation } from 'react-i18next'; +import { AlertTriangle, ChevronDown, Loader2 } from 'lucide-react'; import { httpClient } from '@/app/infra/http/HttpClient'; import { getCurrentWorkspaceSnapshot } from '@/app/infra/http/currentWorkspaceStore'; import { migrationIssueKey } from './pipeline-migration-issues'; import type { CurrentWorkspace } from '@/app/infra/entities/workspace'; import type { PipelineMigrationIssue, - PipelineMigrationItem, PipelineMigrationPreview, PipelineMigrationResult, } from '@/app/infra/entities/api/pipeline-migration'; import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'; import { Badge } from '@/components/ui/badge'; import { Button } from '@/components/ui/button'; -import { Checkbox } from '@/components/ui/checkbox'; import { Dialog, DialogContent, @@ -22,6 +21,11 @@ import { DialogHeader, DialogTitle, } from '@/components/ui/dialog'; +import { + Collapsible, + CollapsibleContent, + CollapsibleTrigger, +} from '@/components/ui/collapsible'; import { ScrollArea } from '@/components/ui/scroll-area'; export function migrationWorkspaceKey(workspace: CurrentWorkspace | null) { @@ -30,12 +34,6 @@ export function migrationWorkspaceKey(workspace: CurrentWorkspace | null) { : ''; } -function safeField(field?: string | null) { - return field && field.length <= 180 && /^[a-zA-Z0-9_.\-[\]]+$/.test(field) - ? field - : null; -} - const resultStates = new Set([ 'pending', 'migrated', @@ -69,8 +67,8 @@ export default function PipelineMigration({ const [loading, setLoading] = useState(false); const [previewError, setPreviewError] = useState(false); const [valid, setValid] = useState(false); - const [selected, setSelected] = useState([]); - const [confirmed, setConfirmed] = useState(false); + const [phase, setPhase] = useState('migrating'); + const [dataOnly, setDataOnly] = useState(false); const [status, setStatus] = useState('idle'); const [results, setResults] = useState([]); const active = useRef(false); @@ -91,8 +89,6 @@ export default function PipelineMigration({ async (allowSelection = true) => { if (!isCurrent()) return; const generation = ++previewGeneration.current; - setSelected([]); - setConfirmed(false); setValid(false); setLoading(true); setPreviewError(false); @@ -139,9 +135,6 @@ export default function PipelineMigration({ }, [refresh]); const busy = status === 'submitting' || status === 'running'; - const eligible = (item: PipelineMigrationItem) => - ['ready', 'activation_pending'].includes(item.state) && - !!item.preview_token; const rows = preview?.items ?? []; const count = rows.filter( (item) => !['already_current', 'not_legacy'].includes(item.state), @@ -149,6 +142,8 @@ export default function PipelineMigration({ function renderIssue(issue: PipelineMigrationIssue, warning = false) { // Unknown server codes use localized fallbacks, never raw upstream messages. + if (issue.code === 'plugin_install_failed') + return {t('pipelineMigration.installFailed')}; const key = migrationIssueKey(issue.code); const fallback = t( warning @@ -158,44 +153,28 @@ export default function PipelineMigration({ const message = key ? t(`pipelineMigration.notices.${key}`, { defaultValue: fallback }) : fallback; - const field = safeField(issue.field); - return ( - - {message} - {field && ( - <> - {' '} - — {field} - - )} - - ); + return {message}; } - async function execute() { + async function execute(installPlugins: boolean) { if ( submitting.current || !isCurrent() || !canManage || !valid || loading || - !confirmed || - selected.length === 0 || - selected.length > 50 + count === 0 ) return; - const items = rows - .filter((item) => selected.includes(item.pipeline_uuid) && eligible(item)) - .map((item) => ({ - pipeline_uuid: item.pipeline_uuid, - preview_token: item.preview_token!, - })); - if (items.length !== selected.length) return; + let items = rows.filter( + (item) => !['already_current', 'not_legacy'].includes(item.state), + ); + setDataOnly(!installPlugins); + setPhase(installPlugins ? 'installing' : 'migrating'); submitting.current = true; ++previewGeneration.current; setStatus('submitting'); setValid(false); - setConfirmed(false); setResults( items.map((item) => ({ pipeline_uuid: item.pipeline_uuid, @@ -212,11 +191,37 @@ export default function PipelineMigration({ onCompleteRef.current(); }; try { - const { task_id } = await httpClient.executePipelineMigration( - { confirmed: true, items }, - { signal: controller.current?.signal }, - ); + const { task_id, pipeline_uuids } = + await httpClient.executePipelineMigration( + { confirmed: true, all: true, install_plugins: installPlugins }, + { signal: controller.current?.signal }, + ); if (!isCurrent()) return; + if ( + !Array.isArray(pipeline_uuids) || + !pipeline_uuids.length || + pipeline_uuids.some((id) => typeof id !== 'string' || !id) || + new Set(pipeline_uuids).size !== pipeline_uuids.length + ) { + loseObservation(); + return; + } + // The server captures the complete workspace set at admission. + items = pipeline_uuids.map( + (id) => + rows.find((row) => row.pipeline_uuid === id) ?? { + pipeline_uuid: id, + name: id, + state: 'ready' as const, + legacy_runner: null, + target_runner_id: null, + target_plugin: null, + changed_paths: [], + warnings: [], + blockers: [], + preview_token: null, + }, + ); setStatus('running'); const poll = async () => { if (!isCurrent()) return; @@ -262,6 +267,8 @@ export default function PipelineMigration({ }, ); setResults(scopedResults); + if (metadata.phase === 'installing' || metadata.phase === 'migrating') + setPhase(metadata.phase); if (task.runtime.done) { if ( !task.runtime.exception && @@ -272,7 +279,7 @@ export default function PipelineMigration({ } submitting.current = false; setStatus(task.runtime.exception ? 'failed' : 'finished'); - void refresh(false); + void refresh(!task.runtime.exception); onCompleteRef.current(); } else { timer.current = setTimeout(() => { @@ -296,15 +303,12 @@ export default function PipelineMigration({ } submitting.current = false; setStatus('requestError'); - setSelected([]); } } function changeOpen(next: boolean) { setOpen(next); if (!submitting.current) { - setSelected([]); - setConfirmed(false); if (next) void refresh(status === 'idle'); } } @@ -312,9 +316,10 @@ export default function PipelineMigration({ return ( <> {(count > 0 || previewError || results.length > 0) && ( - + + )} - - + + {t('pipelineMigration.title')} - {t('pipelineMigration.description')} + {t('pipelineMigration.autoDescription')} - {!canManage && ( - - +
+ {!canManage && ( +

{t('pipelineMigration.readOnly')} - - - )} - {previewError && ( - - +

+ )} + {previewError && ( +

{t('pipelineMigration.previewError')} - - - )} - {status !== 'idle' && ( - - - {t(`pipelineMigration.${status}`)} - - - )} - -

- {rows.map((item) => ( -
-
- = 50 && - !selected.includes(item.pipeline_uuid)) - } - onCheckedChange={(checked) => { - setConfirmed(false); - setSelected((current) => - checked - ? [...current, item.pipeline_uuid] - : current.filter((id) => id !== item.pipeline_uuid), - ); - }} - /> -
-

- {item.name} -

-

- {item.legacy_runner ?? '—'} →{' '} - {item.target_plugin?.name ?? '—'} -

+

+ )} + {busy ? ( +
+ + {t(`pipelineMigration.${phase}`)} +
+ ) : status !== 'idle' ? ( +
+

{t(`pipelineMigration.${status}`)}

+ {results.length > 0 && ( +

+ {t('pipelineMigration.summary', { + migrated: results.filter((r) => + ['migrated', 'already_current'].includes(r.state), + ).length, + remaining: results.filter( + (r) => + !['migrated', 'already_current'].includes(r.state), + ).length, + })} +

+ )} + {dataOnly && results.some((r) => r.state === 'migrated') && ( +

+ {t('pipelineMigration.dataOnlyHint')} +

+ )} +
+ ) : ( +

+ {t('pipelineMigration.detected', { count })} +

+ )} + {!busy && (count > 0 || results.length > 0) && ( + + + + + + +
+ {rows + .filter( + (item) => + !['already_current', 'not_legacy'].includes( + item.state, + ) || + results.some( + (r) => r.pipeline_uuid === item.pipeline_uuid, + ), + ) + .map((item) => { + const result = results.find( + (r) => r.pipeline_uuid === item.pipeline_uuid, + ); + return ( +
+
+ + {item.name} + + + {t( + `pipelineMigration.states.${result?.state ?? item.state}`, + )} + +
+ {item.target_plugin && ( +

+ {item.target_plugin.name} +

+ )} + {result?.code && result.code !== 'data_only' ? ( +

+ {renderIssue({ code: result.code })} +

+ ) : ( + !result && + item.blockers + .filter( + (b) => + ![ + 'plugin_missing', + 'plugin_disabled', + ].includes(b.code), + ) + .map((issue, index) => ( +

+ {renderIssue(issue)} +

+ )) + )} +
+ ); + })}
- - {t(`pipelineMigration.states.${item.state}`)} - -
- {item.blockers.map((issue, index) => ( -

- {renderIssue(issue)} -

- ))} - {item.warnings.map((issue, index) => ( -

- {renderIssue(issue, true)} -

- ))} - {item.changed_paths.filter((path) => safeField(path)).length > - 0 && ( -

- {t('pipelineMigration.changedFields')}:{' '} - {item.changed_paths - .filter((path) => safeField(path)) - .join(', ')} -

- )} - {item.state === 'activation_pending' && ( -

- {t( - item.preview_token - ? 'pipelineMigration.activationRetryHint' - : 'pipelineMigration.activationHint', - )} -

- )} -
- ))} - {rows.some((item) => item.state === 'needs_plugin') && ( - - -

{t('pipelineMigration.pluginHint')}

- -
-
- )} - {results.length > 0 && ( -
+ + + )} +
+ + {!busy && ( + <> +
- )} -
- -

- {t('pipelineMigration.selection', { count: selected.length })} -

- - + {t('pipelineMigration.autoInstall')} + + +

+ {t('pipelineMigration.dataOnlyHint')} +

+ {(status !== 'idle' || previewError) && ( + + )} + + )} - -
diff --git a/web/src/app/home/pipelines/pipeline-migration-issues.ts b/web/src/app/home/pipelines/pipeline-migration-issues.ts index 684b13b96..1b3938adf 100644 --- a/web/src/app/home/pipelines/pipeline-migration-issues.ts +++ b/web/src/app/home/pipelines/pipeline-migration-issues.ts @@ -1,4 +1,6 @@ const issueKeys: Record = { + plugin_missing: 'pluginRequired', + plugin_disabled: 'pluginRequired', 'local.context_defaults': 'contextDefaults', 'local.model_reasoning': 'modelReasoning', 'local.serial_tools_preserved': 'serialTools', diff --git a/web/src/app/infra/entities/api/pipeline-migration.ts b/web/src/app/infra/entities/api/pipeline-migration.ts index 47c59057a..4dad030aa 100644 --- a/web/src/app/infra/entities/api/pipeline-migration.ts +++ b/web/src/app/infra/entities/api/pipeline-migration.ts @@ -31,10 +31,12 @@ export interface PipelineMigrationPreview { total: number; } -export interface PipelineMigrationRequest { - confirmed: true; - items: { pipeline_uuid: string; preview_token: string }[]; -} +export type PipelineMigrationRequest = + | { confirmed: true; all: true; install_plugins: boolean } + | { + confirmed: true; + items: { pipeline_uuid: string; preview_token: string }[]; + }; export interface PipelineMigrationResult { pipeline_uuid: string; @@ -51,5 +53,6 @@ export interface PipelineMigrationResult { export interface PipelineMigrationTaskMetadata { kind: 'pipeline_migration'; + phase?: 'installing' | 'migrating' | 'finished'; results: PipelineMigrationResult[]; } diff --git a/web/src/app/infra/entities/form/dynamic.ts b/web/src/app/infra/entities/form/dynamic.ts index 0b3675ff8..125f0b9bb 100644 --- a/web/src/app/infra/entities/form/dynamic.ts +++ b/web/src/app/infra/entities/form/dynamic.ts @@ -27,6 +27,8 @@ export interface IDynamicFormItemSchema { type: DynamicFormItemType; description?: I18nObject; options?: IDynamicFormItemOption[]; + /** Allow an editable value in addition to the declared select options. */ + allow_custom?: boolean; /** When the condition matches, the field is rendered. Same evaluator as * ``disable_if`` — supports the ``__system.*`` namespace via * ``DynamicFormComponent.systemContext``. */ diff --git a/web/src/app/infra/http/BackendClient.ts b/web/src/app/infra/http/BackendClient.ts index a3ab10966..241d702d7 100644 --- a/web/src/app/infra/http/BackendClient.ts +++ b/web/src/app/infra/http/BackendClient.ts @@ -454,7 +454,7 @@ export class BackendClient extends BaseHttpClient { public executePipelineMigration( body: PipelineMigrationRequest, config?: RequestConfig, - ): Promise { + ): Promise { return this.post('/api/v1/pipelines/_/migration/execute', body, config); } diff --git a/web/src/i18n/locales/en-US.ts b/web/src/i18n/locales/en-US.ts index eaca05ef0..255f6aa57 100644 --- a/web/src/i18n/locales/en-US.ts +++ b/web/src/i18n/locales/en-US.ts @@ -17,6 +17,7 @@ const enUS = { editionCloud: 'Cloud', }, common: { + customValue: 'Custom', loadFailed: 'Failed to load. Please try again.', login: 'Login', logout: 'Logout', diff --git a/web/src/i18n/locales/es-ES.ts b/web/src/i18n/locales/es-ES.ts index ccfccb9d2..92e2c012c 100644 --- a/web/src/i18n/locales/es-ES.ts +++ b/web/src/i18n/locales/es-ES.ts @@ -18,6 +18,7 @@ const esES = { editionCloud: 'Cloud', }, common: { + customValue: 'Personalizado', login: 'Iniciar sesión', logout: 'Cerrar sesión', accountOptions: 'Configuración', diff --git a/web/src/i18n/locales/ja-JP.ts b/web/src/i18n/locales/ja-JP.ts index fd2997385..15d5a80ff 100644 --- a/web/src/i18n/locales/ja-JP.ts +++ b/web/src/i18n/locales/ja-JP.ts @@ -17,6 +17,7 @@ const jaJP = { editionCloud: 'Cloud', }, common: { + customValue: 'カスタム', loadFailed: '読み込みに失敗しました。再試行してください。', login: 'ログイン', logout: 'ログアウト', diff --git a/web/src/i18n/locales/pipeline-migration/en-US.ts b/web/src/i18n/locales/pipeline-migration/en-US.ts index 17dcc9142..bcaa5ebbb 100644 --- a/web/src/i18n/locales/pipeline-migration/en-US.ts +++ b/web/src/i18n/locales/pipeline-migration/en-US.ts @@ -1,7 +1,23 @@ export default { + autoDescription: + 'Legacy runners are now plugins. Migrate all pipelines while keeping your settings. Original configurations are backed up; conversations start fresh.', + viewPipelines: 'View pipelines', + autoInstall: 'Install plugins and migrate', + dataOnly: 'Migrate data only', + dataOnlyHint: + 'For offline or private networks. Install the corresponding runner plugins yourself after migration.', + installing: 'Installing required plugins…', + migrating: 'Migrating pipelines…', + summary: '{{migrated}} migrated; {{remaining}} need attention.', + installFailed: + 'Plugin installation failed. Check your network and extension quota, then retry, or migrate data only.', + activationRetryHint: 'After checking the runtime, refresh, select this pipeline and confirm to retry activation only. Its saved configuration will not be migrated again.', + details: 'Migration details', notices: { + pluginRequired: + 'Install or enable the runner plugin shown above, then refresh the preview.', legacyArchive: 'The active configuration will contain only the selected runner. All original runner settings, including inactive ones, are kept in the migration backup.', contextDefaults: @@ -44,7 +60,7 @@ export default { }, title: 'Pipeline migration', description: - 'Nothing changes until you select pipelines and explicitly confirm. Migration preserves pipeline identity and unrelated settings.', + 'Select pipelines to migrate. Original settings are backed up; conversations start fresh after migration.', detected: '{{count}} pipelines need migration review.', review: 'Review migration', readOnly: 'Only workspace managers can migrate pipelines.', @@ -64,7 +80,7 @@ export default { activationHint: 'Configuration was saved, but activation is pending. Ask a workspace administrator to check runtime availability, then refresh. Do not rerun migration blindly.', pluginHint: - 'Install or enable the required runner plugin through Extensions / Extension Market. Review your workspace extension quota there; this assistant never installs plugins. Return here and refresh the preview.', + 'Missing a plugin? Install or enable its runner in Extensions, then refresh the preview.', extensions: 'Open Extensions', results: 'Per-pipeline results', selection: '{{count}} selected (maximum 50)', diff --git a/web/src/i18n/locales/pipeline-migration/es-ES.ts b/web/src/i18n/locales/pipeline-migration/es-ES.ts index 3f331331a..21dea7f1b 100644 --- a/web/src/i18n/locales/pipeline-migration/es-ES.ts +++ b/web/src/i18n/locales/pipeline-migration/es-ES.ts @@ -1,7 +1,23 @@ export default { + autoDescription: + 'Los ejecutores antiguos ahora son plugins. Migra todos los pipelines conservando sus ajustes. Se guardará una copia de la configuración y las conversaciones empezarán de nuevo.', + viewPipelines: 'Ver pipelines', + autoInstall: 'Instalar plugins y migrar', + dataOnly: 'Migrar solo los datos', + dataOnlyHint: + 'Para redes privadas o sin conexión. Instala los plugins de ejecución correspondientes después de la migración.', + installing: 'Instalando los plugins necesarios…', + migrating: 'Migrando pipelines…', + summary: '{{migrated}} migrados; {{remaining}} requieren atención.', + installFailed: + 'No se pudieron instalar los plugins. Comprueba la red y la cuota de extensiones y reintenta, o migra solo los datos.', + activationRetryHint: 'Tras comprobar el entorno, actualiza, selecciona esta canalización y confirma para reintentar solo la activación. No se volverá a migrar la configuración guardada.', + details: 'Detalles de migración', notices: { + pluginRequired: + 'Instala o activa el plugin de ejecución indicado arriba y actualiza la vista previa.', legacyArchive: 'La configuración activa contendrá solo el Runner seleccionado. Todos los ajustes anteriores, incluidos los inactivos, se conservan en la copia de seguridad de migración.', contextDefaults: @@ -50,7 +66,7 @@ export default { }, title: 'Migración de pipelines', description: - 'No se cambia nada hasta seleccionar pipelines y confirmar explícitamente. Se conservan su identidad y los ajustes no relacionados.', + 'Selecciona los pipelines que deseas migrar. Se guardará una copia de la configuración y las conversaciones empezarán de nuevo.', detected: '{{count}} pipelines requieren revisión.', review: 'Revisar migración', readOnly: 'Solo los gestores del espacio pueden migrar pipelines.', @@ -69,7 +85,7 @@ export default { activationHint: 'Configuración guardada, pero activación pendiente. Pida al administrador que compruebe el entorno y actualice. No repita la migración a ciegas.', pluginHint: - 'Instale o active el plugin requerido desde Extensiones / Mercado de extensiones y compruebe la cuota del espacio. Este asistente nunca instala plugins. Al volver, actualice la vista previa.', + '¿Falta un plugin? Instala o activa su ejecutor en Extensiones y actualiza la vista previa.', extensions: 'Abrir Extensiones', results: 'Resultados por pipeline', selection: '{{count}} seleccionados (máximo 50)', diff --git a/web/src/i18n/locales/pipeline-migration/ja-JP.ts b/web/src/i18n/locales/pipeline-migration/ja-JP.ts index e059057e3..4e6486b07 100644 --- a/web/src/i18n/locales/pipeline-migration/ja-JP.ts +++ b/web/src/i18n/locales/pipeline-migration/ja-JP.ts @@ -1,7 +1,23 @@ export default { + autoDescription: + '従来の実行方式はプラグインになりました。設定を保持して全パイプラインを移行します。元の設定はバックアップされ、会話は新しく始まります。', + viewPipelines: 'パイプラインを表示', + autoInstall: 'プラグインを自動インストールして移行', + dataOnly: 'データのみ移行', + dataOnlyHint: + 'オフラインやイントラネット向けです。移行後、対応するランナープラグインを手動でインストールしてください。', + installing: '必要なプラグインをインストール中…', + migrating: 'パイプラインを移行中…', + summary: '{{migrated}} 件移行済み、{{remaining}} 件の確認が必要です。', + installFailed: + 'プラグインのインストールに失敗しました。ネットワークと拡張機能の上限を確認して再試行するか、データのみ移行してください。', + activationRetryHint: '実行環境を確認して再読み込みし、このパイプラインを選択して確定すると、有効化のみを再試行します。保存済み設定は再移行しません。', + details: '移行の詳細', notices: { + pluginRequired: + '上記のランナープラグインをインストールまたは有効化し、プレビューを更新してください。', legacyArchive: '有効な設定には選択した Runner のみを残します。未使用のものを含むすべての旧 Runner 設定は移行バックアップに保存します。', contextDefaults: @@ -44,7 +60,7 @@ export default { }, title: 'パイプライン移行', description: - 'パイプラインを選択し、明示的に確認するまで変更されません。識別情報と無関係な設定は保持されます。', + '移行するパイプラインを選択してください。元の設定はバックアップされ、移行後は新しい会話を開始します。', detected: '{{count}} 件のパイプラインに移行確認が必要です。', review: '移行を確認', readOnly: '移行できるのはワークスペースの管理権限を持つユーザーのみです。', @@ -63,7 +79,7 @@ export default { activationHint: '設定は保存されましたが、有効化待ちです。管理者に実行環境の確認を依頼してから更新してください。移行を無条件に再実行しないでください。', pluginHint: - '「拡張機能 / 拡張機能マーケット」で必要なランナープラグインをインストールまたは有効化し、ワークスペースの上限を確認してください。このアシスタントはインストールしません。戻った後にプレビューを更新してください。', + 'プラグインが不足している場合は、拡張機能でランナーをインストールまたは有効化し、プレビューを更新してください。', extensions: '拡張機能を開く', results: 'パイプラインごとの結果', selection: '{{count}} 件選択(最大 50 件)', diff --git a/web/src/i18n/locales/pipeline-migration/ru-RU.ts b/web/src/i18n/locales/pipeline-migration/ru-RU.ts index 734ed2529..b9445e8fd 100644 --- a/web/src/i18n/locales/pipeline-migration/ru-RU.ts +++ b/web/src/i18n/locales/pipeline-migration/ru-RU.ts @@ -1,7 +1,23 @@ export default { + autoDescription: + 'Прежние исполнители стали плагинами. Все конвейеры будут перенесены с сохранением настроек. Исходные настройки будут скопированы, а разговоры начнутся заново.', + viewPipelines: 'Посмотреть конвейеры', + autoInstall: 'Установить плагины и перенести', + dataOnly: 'Перенести только данные', + dataOnlyHint: + 'Для закрытых сетей и работы без интернета. После переноса установите соответствующие плагины исполнителей вручную.', + installing: 'Установка необходимых плагинов…', + migrating: 'Перенос конвейеров…', + summary: 'Перенесено: {{migrated}}; требуют внимания: {{remaining}}.', + installFailed: + 'Не удалось установить плагины. Проверьте сеть и квоту расширений и повторите попытку либо перенесите только данные.', + activationRetryHint: 'Проверьте среду выполнения, обновите список, выберите конвейер и подтвердите повторную активацию. Сохранённая конфигурация не будет мигрировать повторно.', + details: 'Подробности переноса', notices: { + pluginRequired: + 'Установите или включите указанный выше плагин исполнителя, затем обновите предпросмотр.', legacyArchive: 'В активной конфигурации останется только выбранный Runner. Все прежние настройки, включая неиспользуемые, сохраняются в резервной копии миграции.', contextDefaults: @@ -47,7 +63,7 @@ export default { }, title: 'Миграция конвейеров', description: - 'Изменения выполняются только после выбора конвейеров и явного подтверждения. Идентификаторы и прочие настройки сохраняются.', + 'Выберите конвейеры для переноса. Исходные настройки сохранятся в резервной копии, а разговоры начнутся заново.', detected: '{{count}} конвейеров требуют проверки миграции.', review: 'Проверить миграцию', readOnly: @@ -66,7 +82,7 @@ export default { activationHint: 'Настройки сохранены, но активация ожидается. Попросите администратора проверить среду и обновите предпросмотр. Не повторяйте миграцию вслепую.', pluginHint: - 'Установите или включите нужный плагин через «Расширения / Маркет» и проверьте квоту пространства. Этот помощник не устанавливает плагины. Вернувшись, обновите предпросмотр.', + 'Не хватает плагина? Установите или включите его в расширениях, затем обновите предпросмотр.', extensions: 'Открыть расширения', results: 'Результаты по конвейерам', selection: 'Выбрано {{count}} (максимум 50)', diff --git a/web/src/i18n/locales/pipeline-migration/th-TH.ts b/web/src/i18n/locales/pipeline-migration/th-TH.ts index 126767680..cf4023cb9 100644 --- a/web/src/i18n/locales/pipeline-migration/th-TH.ts +++ b/web/src/i18n/locales/pipeline-migration/th-TH.ts @@ -1,7 +1,23 @@ export default { + autoDescription: + 'รันเนอร์เดิมเปลี่ยนเป็นปลั๊กอินแล้ว ย้ายไปป์ไลน์ทั้งหมดโดยคงการตั้งค่าเดิม ระบบจะสำรองการตั้งค่าและเริ่มการสนทนาใหม่', + viewPipelines: 'ดูไปป์ไลน์', + autoInstall: 'ติดตั้งปลั๊กอินและย้ายข้อมูล', + dataOnly: 'ย้ายเฉพาะข้อมูล', + dataOnlyHint: + 'สำหรับเครือข่ายภายในหรือออฟไลน์ โปรดติดตั้งปลั๊กอินรันเนอร์ที่เกี่ยวข้องด้วยตนเองหลังย้ายข้อมูล', + installing: 'กำลังติดตั้งปลั๊กอินที่จำเป็น…', + migrating: 'กำลังย้ายไปป์ไลน์…', + summary: 'ย้ายแล้ว {{migrated}} รายการ ต้องตรวจสอบ {{remaining}} รายการ', + installFailed: + 'ติดตั้งปลั๊กอินไม่สำเร็จ ตรวจสอบเครือข่ายและโควตาส่วนขยายแล้วลองใหม่ หรือย้ายเฉพาะข้อมูล', + activationRetryHint: 'หลังตรวจสอบสภาพแวดล้อมการทำงาน ให้รีเฟรช เลือกไปป์ไลน์นี้และยืนยันเพื่อลองเปิดใช้งานอีกครั้งเท่านั้น การตั้งค่าที่บันทึกไว้จะไม่ถูกย้ายซ้ำ', + details: 'รายละเอียดการย้าย', notices: { + pluginRequired: + 'ติดตั้งหรือเปิดใช้งานปลั๊กอินรันเนอร์ที่แสดงด้านบน แล้วรีเฟรชตัวอย่าง', legacyArchive: 'การตั้งค่าที่ใช้งานจะเหลือเฉพาะ Runner ที่เลือก การตั้งค่า Runner เดิมทั้งหมด รวมถึงส่วนที่ไม่ได้ใช้งาน จะถูกเก็บไว้ในข้อมูลสำรองการย้าย', contextDefaults: @@ -41,7 +57,7 @@ export default { }, title: 'ย้ายไปป์ไลน์', description: - 'จะไม่มีการเปลี่ยนแปลงจนกว่าคุณจะเลือกไปป์ไลน์และยืนยันอย่างชัดเจน โดยคงตัวตนและการตั้งค่าอื่นไว้', + 'เลือกไปป์ไลน์ที่จะย้าย ระบบจะสำรองการตั้งค่าเดิมและเริ่มการสนทนาใหม่หลังย้าย', detected: 'มี {{count}} ไปป์ไลน์ที่ต้องตรวจสอบการย้าย', review: 'ตรวจสอบการย้าย', readOnly: 'เฉพาะผู้มีสิทธิ์จัดการพื้นที่ทำงานเท่านั้นที่ย้ายไปป์ไลน์ได้', @@ -59,7 +75,7 @@ export default { activationHint: 'บันทึกการตั้งค่าแล้วแต่รอเปิดใช้งาน โปรดให้ผู้ดูแลตรวจสอบสภาพแวดล้อมแล้วรีเฟรช อย่าย้ายซ้ำโดยไม่ตรวจสอบ', pluginHint: - 'ติดตั้งหรือเปิดใช้ปลั๊กอินรันเนอร์ผ่านส่วนขยาย / ตลาดส่วนขยาย และตรวจสอบโควตาพื้นที่ทำงานด้วยตนเอง ผู้ช่วยนี้ไม่ติดตั้งปลั๊กอิน เมื่อกลับมาโปรดรีเฟรชตัวอย่าง', + 'ขาดปลั๊กอิน? ติดตั้งหรือเปิดใช้งานรันเนอร์ในส่วนขยาย แล้วรีเฟรชตัวอย่าง', extensions: 'เปิดส่วนขยาย', results: 'ผลลัพธ์แต่ละไปป์ไลน์', selection: 'เลือก {{count}} รายการ (สูงสุด 50)', diff --git a/web/src/i18n/locales/pipeline-migration/vi-VN.ts b/web/src/i18n/locales/pipeline-migration/vi-VN.ts index d49fb40da..c788edca9 100644 --- a/web/src/i18n/locales/pipeline-migration/vi-VN.ts +++ b/web/src/i18n/locales/pipeline-migration/vi-VN.ts @@ -1,7 +1,23 @@ export default { + autoDescription: + 'Các runner cũ đã chuyển thành plugin. Chuyển đổi tất cả pipeline và giữ lại thiết lập. Cấu hình cũ sẽ được sao lưu; hội thoại sẽ bắt đầu lại.', + viewPipelines: 'Xem pipeline', + autoInstall: 'Cài plugin và chuyển đổi', + dataOnly: 'Chỉ chuyển đổi dữ liệu', + dataOnlyHint: + 'Dành cho mạng nội bộ hoặc ngoại tuyến. Tự cài các plugin runner tương ứng sau khi chuyển đổi.', + installing: 'Đang cài plugin cần thiết…', + migrating: 'Đang chuyển đổi pipeline…', + summary: 'Đã chuyển {{migrated}}; {{remaining}} cần xử lý.', + installFailed: + 'Cài plugin thất bại. Kiểm tra mạng và hạn mức tiện ích rồi thử lại, hoặc chỉ chuyển đổi dữ liệu.', + activationRetryHint: 'Sau khi kiểm tra môi trường chạy, làm mới, chọn pipeline này và xác nhận để chỉ thử kích hoạt lại. Cấu hình đã lưu sẽ không được di chuyển lần nữa.', + details: 'Chi tiết chuyển đổi', notices: { + pluginRequired: + 'Cài đặt hoặc bật plugin runner ở trên, rồi làm mới bản xem trước.', legacyArchive: 'Cấu hình hoạt động chỉ giữ Runner đã chọn. Tất cả thiết lập Runner cũ, kể cả các thiết lập không dùng, được giữ trong bản sao lưu di chuyển.', contextDefaults: @@ -44,7 +60,7 @@ export default { }, title: 'Di chuyển pipeline', description: - 'Chỉ thay đổi sau khi bạn chọn pipeline và xác nhận rõ ràng. Danh tính và các cài đặt không liên quan được giữ nguyên.', + 'Chọn các pipeline cần chuyển đổi. Cấu hình cũ sẽ được sao lưu; các cuộc trò chuyện sẽ bắt đầu lại.', detected: '{{count}} pipeline cần xem xét di chuyển.', review: 'Xem xét di chuyển', readOnly: @@ -63,7 +79,7 @@ export default { activationHint: 'Cấu hình đã lưu nhưng đang chờ kích hoạt. Nhờ quản trị viên kiểm tra môi trường rồi làm mới. Không chạy lại di chuyển một cách mù quáng.', pluginHint: - 'Cài hoặc bật plugin runner cần thiết qua Tiện ích / Chợ tiện ích và kiểm tra hạn mức không gian. Trợ lý này không cài plugin. Khi quay lại, hãy làm mới bản xem trước.', + 'Thiếu plugin? Cài đặt hoặc bật runner trong Tiện ích mở rộng, rồi làm mới bản xem trước.', extensions: 'Mở Tiện ích', results: 'Kết quả từng pipeline', selection: 'Đã chọn {{count}} (tối đa 50)', diff --git a/web/src/i18n/locales/pipeline-migration/zh-Hans.ts b/web/src/i18n/locales/pipeline-migration/zh-Hans.ts index 4fe791bae..49a2a6cd2 100644 --- a/web/src/i18n/locales/pipeline-migration/zh-Hans.ts +++ b/web/src/i18n/locales/pipeline-migration/zh-Hans.ts @@ -1,7 +1,20 @@ export default { + autoDescription: + '旧版运行方式已改为插件。迁移全部流水线并保留现有设置,原配置会自动备份;迁移后开始新会话。', + viewPipelines: '查看流水线', + autoInstall: '自动安装插件并迁移', + dataOnly: '仅迁移数据', + dataOnlyHint: '适合离线或内网环境,迁移后请自行安装对应的运行器插件。', + installing: '正在安装所需插件…', + migrating: '正在迁移流水线…', + summary: '已迁移 {{migrated}} 条,{{remaining}} 条需要处理。', + installFailed: '插件安装失败,请检查网络和扩展配额后重试,或选择仅迁移数据。', + activationRetryHint: '检查运行环境后刷新,选中此流水线并确认,即可仅重试激活,不会再次迁移已保存的配置。', + details: '迁移详情', notices: { + pluginRequired: '请先安装或启用上方所示的运行器插件,再刷新预览。', legacyArchive: '活动配置仅保留所选 Runner;全部旧 Runner 配置(包括未启用的配置)保存在迁移备份中。', contextDefaults: @@ -31,8 +44,7 @@ export default { pendingInteraction: '有会话正在等待输入,请完成或取消后再迁移。', }, title: '流水线迁移', - description: - '仅在选择流水线并明确确认后才会更改配置。迁移保留流水线身份及无关设置。', + description: '选择要迁移的流水线。原配置会备份,迁移后将开始新会话。', detected: '{{count}} 条流水线需要检查迁移。', review: '检查迁移', readOnly: '只有工作空间管理者可以迁移流水线。', @@ -48,8 +60,7 @@ export default { changedFields: '变更字段', activationHint: '配置已保存,但尚未激活。请工作空间管理员检查运行环境后刷新,不要盲目重复迁移。', - pluginHint: - '请通过「扩展 / 扩展市场」安装或启用所需的运行器插件,并自行检查工作空间扩展配额。本助手不会安装插件。返回此处后请刷新预览。', + pluginHint: '缺少插件?前往扩展安装或启用对应运行器,然后刷新预览。', extensions: '打开扩展', results: '逐条迁移结果', selection: '已选择 {{count}} 条(最多 50 条)', diff --git a/web/src/i18n/locales/pipeline-migration/zh-Hant.ts b/web/src/i18n/locales/pipeline-migration/zh-Hant.ts index f5525a52d..a55984997 100644 --- a/web/src/i18n/locales/pipeline-migration/zh-Hant.ts +++ b/web/src/i18n/locales/pipeline-migration/zh-Hant.ts @@ -1,7 +1,20 @@ export default { + autoDescription: + '舊版執行方式已改為外掛。遷移全部流水線並保留現有設定,原設定會自動備份;遷移後開始新對話。', + viewPipelines: '查看流水線', + autoInstall: '自動安裝外掛並遷移', + dataOnly: '僅遷移資料', + dataOnlyHint: '適合離線或內網環境,遷移後請自行安裝對應的執行器外掛。', + installing: '正在安裝所需外掛…', + migrating: '正在遷移流水線…', + summary: '已遷移 {{migrated}} 條,{{remaining}} 條需要處理。', + installFailed: '外掛安裝失敗,請檢查網路和擴充配額後重試,或選擇僅遷移資料。', + activationRetryHint: '檢查執行環境後重新整理,選取此流水線並確認,即可只重試啟用,不會再次遷移已儲存的設定。', + details: '遷移詳情', notices: { + pluginRequired: '請先安裝或啟用上方所示的執行器外掛,再重新整理預覽。', legacyArchive: '作用中的設定僅保留所選 Runner;全部舊 Runner 設定(包括未啟用的設定)保存在遷移備份中。', contextDefaults: @@ -31,8 +44,7 @@ export default { pendingInteraction: '有對話正在等待輸入,請完成或取消後再遷移。', }, title: '流水線遷移', - description: - '僅在選擇流水線並明確確認後才會變更設定。遷移保留流水線身分及無關設定。', + description: '選擇要遷移的流水線。原設定會備份,遷移後將開始新對話。', detected: '{{count}} 條流水線需要檢查遷移。', review: '檢查遷移', readOnly: '只有工作空間管理者可以遷移流水線。', @@ -48,8 +60,7 @@ export default { changedFields: '變更欄位', activationHint: '設定已儲存,但尚未啟用。請工作空間管理員檢查執行環境後重新整理,不要盲目重複遷移。', - pluginHint: - '請透過「擴充功能 / 擴充功能市場」安裝或啟用所需執行器外掛,並自行檢查工作空間配額。本助手不會安裝外掛。返回後請重新整理預覽。', + pluginHint: '缺少外掛?前往擴充功能安裝或啟用對應執行器,再重新整理預覽。', extensions: '開啟擴充功能', results: '逐條遷移結果', selection: '已選擇 {{count}} 條(最多 50 條)', diff --git a/web/src/i18n/locales/ru-RU.ts b/web/src/i18n/locales/ru-RU.ts index c6a750ef0..eb56045b0 100644 --- a/web/src/i18n/locales/ru-RU.ts +++ b/web/src/i18n/locales/ru-RU.ts @@ -18,6 +18,7 @@ const ruRU = { editionCloud: 'Cloud', }, common: { + customValue: 'Свой вариант', login: 'Войти', logout: 'Выйти', accountOptions: 'Настройки', diff --git a/web/src/i18n/locales/th-TH.ts b/web/src/i18n/locales/th-TH.ts index 308e8906c..4b4836fe2 100644 --- a/web/src/i18n/locales/th-TH.ts +++ b/web/src/i18n/locales/th-TH.ts @@ -17,6 +17,7 @@ const thTH = { editionCloud: 'Cloud', }, common: { + customValue: 'กำหนดเอง', login: 'เข้าสู่ระบบ', logout: 'ออกจากระบบ', accountOptions: 'การตั้งค่า', diff --git a/web/src/i18n/locales/vi-VN.ts b/web/src/i18n/locales/vi-VN.ts index 724b60032..d99ea2f8b 100644 --- a/web/src/i18n/locales/vi-VN.ts +++ b/web/src/i18n/locales/vi-VN.ts @@ -18,6 +18,7 @@ const viVN = { editionCloud: 'Cloud', }, common: { + customValue: 'Tùy chỉnh', login: 'Đăng nhập', logout: 'Đăng xuất', accountOptions: 'Cài đặt', diff --git a/web/src/i18n/locales/zh-Hans.ts b/web/src/i18n/locales/zh-Hans.ts index a447dabba..0f0c6449c 100644 --- a/web/src/i18n/locales/zh-Hans.ts +++ b/web/src/i18n/locales/zh-Hans.ts @@ -17,6 +17,7 @@ const zhHans = { editionCloud: 'Cloud', }, common: { + customValue: '自定义', loadFailed: '加载失败,请重试。', login: '登录', logout: '退出登录', diff --git a/web/src/i18n/locales/zh-Hant.ts b/web/src/i18n/locales/zh-Hant.ts index 16e9f9508..511cd5572 100644 --- a/web/src/i18n/locales/zh-Hant.ts +++ b/web/src/i18n/locales/zh-Hant.ts @@ -17,6 +17,7 @@ const zhHant = { editionCloud: 'Cloud', }, common: { + customValue: '自訂', login: '登入', logout: '登出', accountOptions: '系統設定', diff --git a/web/tests/e2e/fixtures/runner-migration-contract.json b/web/tests/e2e/fixtures/runner-migration-contract.json index 7f75aa5c4..4998ea66e 100644 --- a/web/tests/e2e/fixtures/runner-migration-contract.json +++ b/web/tests/e2e/fixtures/runner-migration-contract.json @@ -72,7 +72,9 @@ "base-url": "https://weknora.invalid/api/v1", "api-key": "synthetic-key", "app-type": "agent", - "knowledge-base-ids": ["remote-kb"], + "knowledge-base-ids": [ + "remote-kb" + ], "web-search-enabled": false } }, @@ -84,7 +86,7 @@ "metadata": { "author": "langbot-team", "name": "LocalAgent", - "version": "0.1.6", + "version": "0.1.7", "repository": "https://github.com/langbot-app/langbot-plugins/tree/main/Runner/LocalAgent", "label": { "en_US": "Local Agent", @@ -143,7 +145,9 @@ } }, "spec": { - "usages": ["agent"], + "usages": [ + "agent" + ], "config": [ { "name": "model", @@ -188,6 +192,107 @@ "required": false, "default": [] }, + { + "name": "box-enabled", + "label": { + "en_US": "Sandbox", + "zh_Hans": "沙箱" + }, + "description": { + "en_US": "Use Box for code execution and file processing.", + "zh_Hans": "使用 Box 执行代码和处理文件。" + }, + "type": "boolean", + "required": false, + "default": true + }, + { + "name": "box-session-id-template", + "label": { + "en_US": "Sandbox reuse", + "zh_Hans": "沙箱复用范围" + }, + "description": { + "en_US": "Choose how sandbox files and environments are shared. Custom templates substitute {variable_name}, for example {launcher_type}_{launcher_id}_{sender_id}. Equal results reuse the same sandbox; {global} shares one sandbox throughout the workspace. Available variables: {launcher_type}, {launcher_id}, {sender_id}, {conversation_id}, {bot_id}, {query_id}, {run_id}, {event_id}, {event_type}, and public request variables. Only named variables are supported, not expressions or attribute access; unavailable variables cause an error.", + "zh_Hans": "选择沙箱文件和环境的共享方式。自定义模板使用 {变量名} 插值,如 {launcher_type}_{launcher_id}_{sender_id};插值结果相同就复用同一个沙箱,{global} 表示在工作区内全局共享。可用变量:{launcher_type}(会话类型)、{launcher_id}(会话 ID)、{sender_id}(用户 ID)、{conversation_id}(对话上下文 ID)、{bot_id}(机器人 ID)、{query_id}(请求 ID)、{run_id}(运行 ID)、{event_id}(事件 ID)、{event_type}(事件类型),也可引用公开请求变量。仅支持变量名,不支持运算或属性访问;变量不可用时会报错。" + }, + "type": "select", + "required": true, + "default": "{launcher_type}_{launcher_id}", + "show_if": { + "field": "box-enabled", + "operator": "eq", + "value": true + }, + "allow_custom": true, + "options": [ + { + "name": "{global}", + "label": { + "en_US": "Global (shared by all)", + "zh_Hans": "全局(所有人共享)", + "zh_Hant": "全域(所有人共用)", + "ja_JP": "グローバル(全員共有)", + "vi_VN": "Toàn cục (chia sẻ cho tất cả)", + "th_TH": "ทั่วไป (แชร์ทั้งหมด)", + "es_ES": "Global (compartido por todos)", + "ru_RU": "Глобальный (общий для всех)" + } + }, + { + "name": "{launcher_type}_{launcher_id}", + "label": { + "en_US": "Per chat (Recommended)", + "zh_Hans": "每个会话(推荐)", + "zh_Hant": "每個會話(推薦)", + "ja_JP": "チャットごと(推奨)", + "vi_VN": "Mỗi cuộc trò chuyện (Khuyến nghị)", + "th_TH": "ต่อแชท (แนะนำ)", + "es_ES": "Por chat (Recomendado)", + "ru_RU": "По чату (Рекомендуется)" + } + }, + { + "name": "{launcher_type}_{launcher_id}_{sender_id}", + "label": { + "en_US": "Per user in chat", + "zh_Hans": "会话中每个用户", + "zh_Hant": "會話中每個用戶", + "ja_JP": "チャット内のユーザーごと", + "vi_VN": "Mỗi người dùng trong cuộc trò chuyện", + "th_TH": "ต่อผู้ใช้ในแชท", + "es_ES": "Por usuario en chat", + "ru_RU": "По пользователю в чате" + } + }, + { + "name": "{launcher_type}_{launcher_id}_{conversation_id}", + "label": { + "en_US": "Per conversation context", + "zh_Hans": "每个对话上下文", + "zh_Hant": "每個對話上下文", + "ja_JP": "会話コンテキストごと", + "vi_VN": "Mỗi ngữ cảnh hội thoại", + "th_TH": "ต่อบริบทการสนทนา", + "es_ES": "Por contexto de conversación", + "ru_RU": "По контексту разговора" + } + }, + { + "name": "{query_id}", + "label": { + "en_US": "Per message (isolated)", + "zh_Hans": "每条消息(完全隔离)", + "zh_Hant": "每條訊息(完全隔離)", + "ja_JP": "メッセージごと(隔離)", + "vi_VN": "Mỗi tin nhắn (cách ly)", + "th_TH": "ต่อข้อความ (แยกส่วน)", + "es_ES": "Por mensaje (aislado)", + "ru_RU": "По сообщению (изолированно)" + } + } + ] + }, { "name": "advanced-settings", "label": { @@ -447,10 +552,23 @@ "steering": true }, "permissions": { - "models": ["count_tokens", "invoke", "stream", "rerank"], - "tools": ["detail", "call"], - "knowledge_bases": ["list", "retrieve"], - "history": ["page"] + "models": [ + "count_tokens", + "invoke", + "stream", + "rerank" + ], + "tools": [ + "detail", + "call" + ], + "knowledge_bases": [ + "list", + "retrieve" + ], + "history": [ + "page" + ] } }, "execution": { @@ -529,7 +647,9 @@ } }, "spec": { - "usages": ["agent"], + "usages": [ + "agent" + ], "config": [ { "name": "user-id-source", @@ -803,11 +923,22 @@ "interactions": true }, "permissions": { - "tools": ["detail", "call"], - "knowledge_bases": ["retrieve"], - "history": ["page"], - "storage": ["plugin"], - "interactions": ["request"] + "tools": [ + "detail", + "call" + ], + "knowledge_bases": [ + "retrieve" + ], + "history": [ + "page" + ], + "storage": [ + "plugin" + ], + "interactions": [ + "request" + ] } }, "execution": { @@ -886,7 +1017,9 @@ } }, "spec": { - "usages": ["agent"], + "usages": [ + "agent" + ], "config": [ { "name": "user-id-source", @@ -1104,10 +1237,19 @@ "interrupt": false }, "permissions": { - "tools": ["detail", "call"], - "knowledge_bases": ["retrieve"], - "history": ["page"], - "storage": ["plugin"] + "tools": [ + "detail", + "call" + ], + "knowledge_bases": [ + "retrieve" + ], + "history": [ + "page" + ], + "storage": [ + "plugin" + ] } }, "execution": { @@ -1186,7 +1328,9 @@ } }, "spec": { - "usages": ["agent"], + "usages": [ + "agent" + ], "config": [ { "name": "remove-think", @@ -1386,10 +1530,19 @@ "interrupt": false }, "permissions": { - "tools": ["detail", "call"], - "knowledge_bases": ["retrieve"], - "history": ["page"], - "storage": ["plugin"] + "tools": [ + "detail", + "call" + ], + "knowledge_bases": [ + "retrieve" + ], + "history": [ + "page" + ], + "storage": [ + "plugin" + ] } }, "execution": { @@ -1468,7 +1621,9 @@ } }, "spec": { - "usages": ["agent"], + "usages": [ + "agent" + ], "config": [ { "name": "user-id-source", @@ -1851,10 +2006,19 @@ "interrupt": false }, "permissions": { - "tools": ["detail", "call"], - "knowledge_bases": ["retrieve"], - "history": ["page"], - "storage": ["plugin"] + "tools": [ + "detail", + "call" + ], + "knowledge_bases": [ + "retrieve" + ], + "history": [ + "page" + ], + "storage": [ + "plugin" + ] } }, "execution": { @@ -1933,7 +2097,9 @@ } }, "spec": { - "usages": ["agent"], + "usages": [ + "agent" + ], "config": [ { "name": "base-url", @@ -2118,10 +2284,19 @@ "interrupt": false }, "permissions": { - "tools": ["detail", "call"], - "knowledge_bases": ["retrieve"], - "history": ["page"], - "storage": ["plugin"] + "tools": [ + "detail", + "call" + ], + "knowledge_bases": [ + "retrieve" + ], + "history": [ + "page" + ], + "storage": [ + "plugin" + ] } }, "execution": { @@ -2200,7 +2375,9 @@ } }, "spec": { - "usages": ["agent"], + "usages": [ + "agent" + ], "config": [ { "name": "api-base", @@ -2399,7 +2576,9 @@ "interrupt": false }, "permissions": { - "storage": ["plugin"] + "storage": [ + "plugin" + ] } }, "execution": { @@ -2478,7 +2657,9 @@ } }, "spec": { - "usages": ["agent"], + "usages": [ + "agent" + ], "config": [ { "name": "user-id-source", @@ -2563,7 +2744,9 @@ "interrupt": false }, "permissions": { - "storage": ["plugin"] + "storage": [ + "plugin" + ] } }, "execution": { @@ -2642,7 +2825,9 @@ } }, "spec": { - "usages": ["agent"], + "usages": [ + "agent" + ], "config": [ { "name": "base-url", @@ -2805,7 +2990,9 @@ "interrupt": false }, "permissions": { - "storage": ["plugin"] + "storage": [ + "plugin" + ] } }, "execution": { @@ -2826,7 +3013,9 @@ "trigger": { "group-respond-rules": { "at": true, - "prefix": ["ai"], + "prefix": [ + "ai" + ], "regexp": [], "random": 0.0 }, @@ -2933,7 +3122,9 @@ "base-url": "https://weknora.invalid/api/v1", "api-key": "synthetic-key", "app-type": "agent", - "knowledge-base-ids": ["remote-kb"], + "knowledge-base-ids": [ + "remote-kb" + ], "web-search-enabled": false } }, @@ -2964,13 +3155,15 @@ "target_plugin": { "author": "langbot-team", "name": "LocalAgent", - "version": "0.1.6" + "version": "0.1.7" }, "config": { "trigger": { "group-respond-rules": { "at": true, - "prefix": ["ai"], + "prefix": [ + "ai" + ], "regexp": [], "random": 0.0 }, @@ -3124,7 +3317,9 @@ "trigger": { "group-respond-rules": { "at": true, - "prefix": ["ai"], + "prefix": [ + "ai" + ], "regexp": [], "random": 0.0 }, @@ -3231,7 +3426,9 @@ "base-url": "https://weknora.invalid/api/v1", "api-key": "synthetic-key", "app-type": "agent", - "knowledge-base-ids": ["remote-kb"], + "knowledge-base-ids": [ + "remote-kb" + ], "web-search-enabled": false } }, @@ -3268,7 +3465,9 @@ "trigger": { "group-respond-rules": { "at": true, - "prefix": ["ai"], + "prefix": [ + "ai" + ], "regexp": [], "random": 0.0 }, @@ -3401,7 +3600,9 @@ "trigger": { "group-respond-rules": { "at": true, - "prefix": ["ai"], + "prefix": [ + "ai" + ], "regexp": [], "random": 0.0 }, @@ -3508,7 +3709,9 @@ "base-url": "https://weknora.invalid/api/v1", "api-key": "synthetic-key", "app-type": "agent", - "knowledge-base-ids": ["remote-kb"], + "knowledge-base-ids": [ + "remote-kb" + ], "web-search-enabled": false } }, @@ -3545,7 +3748,9 @@ "trigger": { "group-respond-rules": { "at": true, - "prefix": ["ai"], + "prefix": [ + "ai" + ], "regexp": [], "random": 0.0 }, @@ -3678,7 +3883,9 @@ "trigger": { "group-respond-rules": { "at": true, - "prefix": ["ai"], + "prefix": [ + "ai" + ], "regexp": [], "random": 0.0 }, @@ -3785,7 +3992,9 @@ "base-url": "https://weknora.invalid/api/v1", "api-key": "synthetic-key", "app-type": "agent", - "knowledge-base-ids": ["remote-kb"], + "knowledge-base-ids": [ + "remote-kb" + ], "web-search-enabled": false } }, @@ -3822,7 +4031,9 @@ "trigger": { "group-respond-rules": { "at": true, - "prefix": ["ai"], + "prefix": [ + "ai" + ], "regexp": [], "random": 0.0 }, @@ -3946,7 +4157,9 @@ "trigger": { "group-respond-rules": { "at": true, - "prefix": ["ai"], + "prefix": [ + "ai" + ], "regexp": [], "random": 0.0 }, @@ -4053,7 +4266,9 @@ "base-url": "https://weknora.invalid/api/v1", "api-key": "synthetic-key", "app-type": "agent", - "knowledge-base-ids": ["remote-kb"], + "knowledge-base-ids": [ + "remote-kb" + ], "web-search-enabled": false } }, @@ -4090,7 +4305,9 @@ "trigger": { "group-respond-rules": { "at": true, - "prefix": ["ai"], + "prefix": [ + "ai" + ], "regexp": [], "random": 0.0 }, @@ -4225,7 +4442,9 @@ "trigger": { "group-respond-rules": { "at": true, - "prefix": ["ai"], + "prefix": [ + "ai" + ], "regexp": [], "random": 0.0 }, @@ -4332,7 +4551,9 @@ "base-url": "https://weknora.invalid/api/v1", "api-key": "synthetic-key", "app-type": "agent", - "knowledge-base-ids": ["remote-kb"], + "knowledge-base-ids": [ + "remote-kb" + ], "web-search-enabled": false } }, @@ -4369,7 +4590,9 @@ "trigger": { "group-respond-rules": { "at": true, - "prefix": ["ai"], + "prefix": [ + "ai" + ], "regexp": [], "random": 0.0 }, @@ -4497,7 +4720,9 @@ "trigger": { "group-respond-rules": { "at": true, - "prefix": ["ai"], + "prefix": [ + "ai" + ], "regexp": [], "random": 0.0 }, @@ -4604,7 +4829,9 @@ "base-url": "https://weknora.invalid/api/v1", "api-key": "synthetic-key", "app-type": "agent", - "knowledge-base-ids": ["remote-kb"], + "knowledge-base-ids": [ + "remote-kb" + ], "web-search-enabled": false } }, @@ -4641,7 +4868,9 @@ "trigger": { "group-respond-rules": { "at": true, - "prefix": ["ai"], + "prefix": [ + "ai" + ], "regexp": [], "random": 0.0 }, @@ -4760,7 +4989,9 @@ "trigger": { "group-respond-rules": { "at": true, - "prefix": ["ai"], + "prefix": [ + "ai" + ], "regexp": [], "random": 0.0 }, @@ -4867,7 +5098,9 @@ "base-url": "https://weknora.invalid/api/v1", "api-key": "synthetic-key", "app-type": "agent", - "knowledge-base-ids": ["remote-kb"], + "knowledge-base-ids": [ + "remote-kb" + ], "web-search-enabled": false } }, @@ -4904,7 +5137,9 @@ "trigger": { "group-respond-rules": { "at": true, - "prefix": ["ai"], + "prefix": [ + "ai" + ], "regexp": [], "random": 0.0 }, @@ -5025,7 +5260,9 @@ "trigger": { "group-respond-rules": { "at": true, - "prefix": ["ai"], + "prefix": [ + "ai" + ], "regexp": [], "random": 0.0 }, @@ -5132,7 +5369,9 @@ "base-url": "https://weknora.invalid/api/v1", "api-key": "synthetic-key", "app-type": "agent", - "knowledge-base-ids": ["remote-kb"], + "knowledge-base-ids": [ + "remote-kb" + ], "web-search-enabled": false } }, @@ -5169,7 +5408,9 @@ "trigger": { "group-respond-rules": { "at": true, - "prefix": ["ai"], + "prefix": [ + "ai" + ], "regexp": [], "random": 0.0 }, @@ -5208,7 +5449,9 @@ }, "runner_config": { "plugin:langbot-team/WeKnoraAgent/default": { - "knowledge-base-ids": ["remote-kb"], + "knowledge-base-ids": [ + "remote-kb" + ], "web-search-enabled": false, "timeout": 120, "base-prompt": "请回答用户的问题。", diff --git a/web/tests/e2e/pipeline-migration.spec.ts b/web/tests/e2e/pipeline-migration.spec.ts index 83deaf8a2..a95944c42 100644 --- a/web/tests/e2e/pipeline-migration.spec.ts +++ b/web/tests/e2e/pipeline-migration.spec.ts @@ -79,6 +79,7 @@ async function setup( taskException: false, results: [ { pipeline_uuid: 'one', state: 'migrated', code: null }, + { pipeline_uuid: 'two', state: 'migrated', code: null }, ] as PipelineMigrationResult[], holdExecute: null as Promise | null, holdPreview: null as Promise | null, @@ -118,7 +119,12 @@ async function setup( status: state.executeStatus, json: { code: state.executeStatus, msg: 'unsafe-upstream-secret' }, }); - await reply(route, { task_id: 411 }); + await reply(route, { + task_id: 411, + pipeline_uuids: state.items + .filter((r) => !['already_current', 'not_legacy'].includes(r.state)) + .map((r) => r.pipeline_uuid), + }); }); await page.route('**/api/v1/system/tasks/411', async (route) => { state.polls++; @@ -178,229 +184,6 @@ async function open(page: Page, detail = false) { .click(); return page.getByRole('dialog', { name: 'Pipeline migration' }); } -async function selectAndConfirm(page: Page) { - const dialog = page.getByRole('dialog', { name: 'Pipeline migration' }); - await dialog - .getByRole('checkbox', { name: 'Legacy one', exact: true }) - .check(); - await dialog - .getByRole('checkbox', { - name: 'I confirm migration of the selected pipelines.', - }) - .check(); - return dialog.getByRole('button', { name: 'Migrate selected', exact: true }); -} - -test('opening, refreshing and cancelling never execute; selection starts empty', async ({ - page, -}) => { - const state = await setup(page); - const dialog = await open(page); - await expect( - dialog.getByRole('checkbox', { name: 'Legacy one', exact: true }), - ).not.toBeChecked(); - await expect( - dialog.getByRole('button', { name: 'Migrate selected', exact: true }), - ).toBeDisabled(); - await dialog.getByRole('button', { name: 'Refresh preview' }).click(); - await expect.poll(() => state.previews).toBeGreaterThan(1); - await dialog.getByRole('button', { name: 'Cancel', exact: true }).click(); - await page - .getByRole('button', { name: 'Review migration', exact: true }) - .click(); - await expect( - dialog.getByRole('checkbox', { name: 'Legacy one', exact: true }), - ).not.toBeChecked(); - expect(state.posts).toEqual([]); - await page.screenshot({ path: 'test-results/pipeline-migration-review.png' }); -}); - -test('explicit selection plus confirmation sends only IDs/tokens and suppresses double submit', async ({ - page, -}) => { - const state = await setup(page); - let release!: () => void; - state.holdExecute = new Promise((resolve) => { - release = resolve; - }); - const dialog = await open(page); - await dialog - .getByRole('checkbox', { name: 'Legacy one', exact: true }) - .check(); - await expect( - dialog.getByRole('button', { name: 'Migrate selected', exact: true }), - ).toBeDisabled(); - await dialog - .getByRole('checkbox', { - name: 'I confirm migration of the selected pipelines.', - }) - .check(); - await dialog - .getByRole('button', { name: 'Migrate selected', exact: true }) - .evaluate((button: HTMLButtonElement) => { - button.click(); - button.click(); - }); - await expect.poll(() => state.posts.length).toBe(1); - await expect( - dialog.getByRole('button', { name: 'Migrate selected', exact: true }), - ).toBeDisabled(); - expect(state.posts).toEqual([ - { - confirmed: true, - items: [{ pipeline_uuid: 'one', preview_token: 'token-one' }], - }, - ]); - release(); - await expect(dialog.getByTestId('migration-result-one')).toContainText( - 'Migrated', - ); -}); - -test('view-only workspace can inspect but cannot select or execute', async ({ - page, -}) => { - const state = await setup(page, { viewer: true, cloud: true }); - const dialog = await open(page); - await expect( - dialog.getByText('Only workspace managers can migrate pipelines.'), - ).toBeVisible(); - await expect( - dialog.getByRole('checkbox', { name: 'Legacy one', exact: true }), - ).toBeDisabled(); - await expect( - dialog.getByRole('button', { name: 'Migrate selected', exact: true }), - ).toBeDisabled(); - expect(state.posts).toEqual([]); -}); - -test('missing plugins and blocked rows stay unselectable; existing Extensions flow and safe fields', async ({ - page, -}) => { - const state = await setup(page); - state.items = [ - row('one', 'needs_plugin'), - { - ...row('two', 'blocked'), - blockers: [ - { code: 'unsupported_field', field: 'ai.local-agent.max-round' }, - ], - }, - ]; - const dialog = await open(page); - for (const name of ['Legacy one', 'Legacy two']) - await expect( - dialog.getByRole('checkbox', { name, exact: true }), - ).toBeDisabled(); - await expect(dialog.getByText('ai.local-agent.max-round')).toBeVisible(); - await expect(dialog.getByText(/quota/)).toBeVisible(); - await expect( - dialog.getByRole('link', { name: 'Open Extensions' }), - ).toHaveAttribute('href', '/home/extensions'); - await expect(dialog).not.toContainText('fixture-not-a-secret'); - state.items = [row('one')]; - await page.evaluate(() => window.dispatchEvent(new Event('focus'))); - await expect( - dialog.getByRole('checkbox', { name: 'Legacy one', exact: true }), - ).toBeEnabled(); - expect(state.posts).toEqual([]); -}); - -test('stale execute requires a fresh preview and new explicit selection', async ({ - page, -}) => { - const state = await setup(page); - state.executeStatus = 409; - const dialog = await open(page); - await (await selectAndConfirm(page)).click(); - await expect( - dialog.getByText( - 'Request not completed. Refresh the preview before selecting again.', - ), - ).toBeVisible(); - await expect( - dialog.getByRole('button', { name: 'Migrate selected', exact: true }), - ).toBeDisabled(); - await expect(dialog).not.toContainText('unsafe-upstream-secret'); - await dialog.getByRole('button', { name: 'Refresh preview' }).click(); - await expect( - dialog.getByRole('checkbox', { name: 'Legacy one', exact: true }), - ).not.toBeChecked(); - expect(state.posts).toHaveLength(1); -}); - -test('partial task results are shown per row without a global success claim', async ({ - page, -}) => { - const state = await setup(page, { cloud: true }); - state.results = [ - { pipeline_uuid: 'one', state: 'migrated', code: null }, - { - pipeline_uuid: 'two', - state: 'failed', - code: 'runtime_unavailable', - }, - ]; - const dialog = await open(page); - const submit = await selectAndConfirm(page); - await dialog - .getByRole('checkbox', { name: 'Legacy two', exact: true }) - .check(); - await expect(submit).toBeDisabled(); - await dialog - .getByRole('checkbox', { - name: 'I confirm migration of the selected pipelines.', - }) - .check(); - await submit.click(); - await expect(dialog.getByTestId('migration-result-one')).toContainText( - 'Migrated', - ); - await expect(dialog.getByTestId('migration-result-two')).toContainText( - 'Failed', - ); - await expect( - dialog.getByText('Task finished. Check each pipeline result below.'), - ).toBeVisible(); - await page.screenshot({ - path: 'test-results/pipeline-migration-partial.png', - }); -}); - -test('task failure stays distinct from polling loss and never displays raw exceptions', async ({ - page, -}) => { - const state = await setup(page); - state.taskException = true; - state.results = [{ pipeline_uuid: 'one', state: 'failed', code: null }]; - const dialog = await open(page); - await (await selectAndConfirm(page)).click(); - await expect( - dialog.getByText('Task failed. Check each pipeline result below.'), - ).toBeVisible(); - await expect(dialog).not.toContainText('unsafe-upstream-secret'); -}); - -test('lost polling is observation lost, refreshes read-only preview and never retries execute', async ({ - page, -}) => { - const state = await setup(page); - state.pollLost = true; - const dialog = await open(page); - const before = state.previews; - await (await selectAndConfirm(page)).click(); - await expect( - dialog.getByText( - 'Task observation lost. Its outcome is unknown; refresh the preview before any further action.', - ), - ).toBeVisible(); - await expect.poll(() => state.previews).toBeGreaterThan(before); - expect(state.posts).toHaveLength(1); - await expect( - dialog.getByRole('button', { name: 'Migrate selected', exact: true }), - ).toBeDisabled(); -}); - test('legacy detail never mounts editable runner defaults or debug autosave, metadata stays available', async ({ page, }) => { @@ -422,26 +205,195 @@ test('legacy detail never mounts editable runner defaults or debug autosave, met expect(state.posts).toEqual([]); }); -test('completion reloads detail and runner metadata; current pipeline keeps normal editor', async ({ +const installButton = (page: Page) => + page.getByRole('button', { + name: 'Install plugins and migrate', + exact: true, + }); +const dataButton = (page: Page) => + page.getByRole('button', { name: 'Migrate data only', exact: true }); + +test('compact assistant hides details, has no checkboxes and does not execute on open or close', async ({ page, }) => { const state = await setup(page); - const dialog = await open(page, true); - const before = state.pipelineReads; - await (await selectAndConfirm(page)).click(); - await expect(dialog.getByTestId('migration-result-one')).toContainText( - 'Migrated', - ); - await dialog.getByRole('button', { name: 'Cancel', exact: true }).click(); - await expect( - page.getByRole('tab', { name: 'AI', exact: true }), - ).toBeVisible(); - await expect.poll(() => state.pipelineReads).toBeGreaterThan(before); - await expect.poll(() => state.metadataReads).toBeGreaterThan(0); - expect(state.writes).toEqual([]); + const dialog = await open(page); + await expect(dialog.getByRole('checkbox')).toHaveCount(0); + await expect(dialog.getByText('Legacy one', { exact: true })).toHaveCount(0); + await expect(installButton(page)).toBeEnabled(); + await expect(dataButton(page)).toBeEnabled(); + await dialog.getByRole('button', { name: 'View pipelines' }).click(); + await expect(dialog.getByText('Legacy one', { exact: true })).toBeVisible(); + await expect(dialog).not.toContainText('ai.runner.id'); + await dialog + .getByRole('button', { name: 'Close', exact: true }) + .first() + .click(); + expect(state.posts).toEqual([]); }); -test('workspace change resets selection and ignores an old polling response', async ({ +for (const install of [true, false]) { + test(`one click migrates all pipelines, install_plugins=${install}`, async ({ + page, + }) => { + const state = await setup(page); + state.items.push(row('missing', 'needs_plugin')); + state.results.push({ + pipeline_uuid: 'missing', + state: 'migrated', + code: install ? null : 'data_only', + }); + const dialog = await open(page); + await (install ? installButton(page) : dataButton(page)).click(); + await expect(dialog).toContainText('3 migrated; 0 need attention.'); + expect(state.posts).toEqual([ + { confirmed: true, all: true, install_plugins: install }, + ]); + expect(state.writes).toEqual([]); + if (!install) + await expect(dialog).toContainText( + 'Install the corresponding runner plugins yourself', + ); + }); +} + +test('read-only users can inspect but cannot migrate', async ({ page }) => { + const state = await setup(page, { viewer: true }); + await open(page); + await expect(installButton(page)).toBeDisabled(); + await expect(dataButton(page)).toBeDisabled(); + expect(state.posts).toEqual([]); +}); + +test('double clicks do not submit duplicate tasks; close does not cancel running task', async ({ + page, +}) => { + const state = await setup(page); + state.done = false; + const dialog = await open(page); + await installButton(page).dblclick(); + await expect(dialog.getByRole('status')).toContainText( + 'Installing required plugins', + ); + await expect(installButton(page)).toHaveCount(0); + await dialog + .getByRole('button', { name: 'Close', exact: true }) + .first() + .click(); + await page + .getByRole('button', { name: 'Review migration', exact: true }) + .click(); + await expect(dialog.getByRole('status')).toContainText( + 'Installing required plugins', + ); + expect(state.posts).toHaveLength(1); + state.done = true; + await expect(dialog).toContainText('2 migrated; 0 need attention.'); +}); + +test('partial failures are reported without exposing upstream errors, and refresh allows retry', async ({ + page, +}) => { + const state = await setup(page); + state.results = [ + { pipeline_uuid: 'one', state: 'migrated', code: null }, + { pipeline_uuid: 'two', state: 'blocked', code: 'plugin_install_failed' }, + ]; + const dialog = await open(page); + await installButton(page).click(); + await expect(dialog).toContainText('1 migrated; 1 need attention.'); + await dialog.getByRole('button', { name: 'View pipelines' }).click(); + await expect(dialog.getByTestId('migration-result-two')).toContainText( + 'Plugin installation failed', + ); + await expect(dialog).not.toContainText('unsafe-upstream-secret'); + await expect(installButton(page)).toBeEnabled(); + await dialog.getByRole('button', { name: 'Refresh preview' }).click(); + await expect(installButton(page)).toBeEnabled(); + expect(state.posts).toHaveLength(1); +}); + +test('all pipelines including more than fifty can be migrated, expanded list scrolls without moving actions', async ({ + page, +}) => { + await page.setViewportSize({ width: 1024, height: 650 }); + const state = await setup(page); + state.items = Array.from({ length: 65 }, (_, i) => row(`many-${i}`)); + state.results = state.items.map((r) => ({ + pipeline_uuid: r.pipeline_uuid, + state: 'migrated', + code: null, + })); + const dialog = await open(page); + await dialog.getByRole('button', { name: 'View pipelines' }).click(); + const viewport = dialog + .getByTestId('migration-scroll-area') + .locator('[data-slot="scroll-area-viewport"]'); + const before = await installButton(page).boundingBox(); + const bounds = await viewport.boundingBox(); + await page.mouse.move( + bounds!.x + bounds!.width / 2, + bounds!.y + bounds!.height / 2, + ); + await page.mouse.wheel(0, 20000); + await expect + .poll(() => + viewport.evaluate( + (el) => el.scrollTop + el.clientHeight >= el.scrollHeight - 2, + ), + ) + .toBe(true); + await expect( + dialog.getByText('Legacy many-64', { exact: true }), + ).toBeInViewport(); + expect((await installButton(page).boundingBox())!.y).toBeCloseTo( + before!.y, + 0, + ); + await expect(installButton(page)).toBeInViewport(); + await dataButton(page).click(); + await expect(dialog).toContainText('65 migrated; 0 need attention.'); + expect(state.posts).toEqual([ + { confirmed: true, all: true, install_plugins: false }, + ]); +}); + +for (const failure of [ + 'missing', + 'extra', + 'duplicate', + 'invalid-state', + 'pending', + 'poll-lost', + 'task-error', +]) { + test(`task result boundary: ${failure}`, async ({ page }) => { + const state = await setup(page); + if (failure === 'missing') state.results.pop(); + if (failure === 'extra') + state.results.push({ + pipeline_uuid: 'foreign', + state: 'migrated', + code: null, + }); + if (failure === 'duplicate') state.results.push(state.results[0]); + if (failure === 'invalid-state') + state.results[0].state = 'bogus' as PipelineMigrationResult['state']; + if (failure === 'pending') state.results[0].state = 'pending'; + if (failure === 'poll-lost') state.pollLost = true; + if (failure === 'task-error') state.taskException = true; + const dialog = await open(page); + await installButton(page).click(); + await expect(dialog).toContainText( + failure === 'task-error' ? 'Task failed' : 'Task observation lost', + ); + await expect(installButton(page)).toBeDisabled(); + await expect(dialog).not.toContainText('unsafe-upstream-secret'); + expect(state.posts).toHaveLength(1); + }); +} + +test('workspace changes discard previous task results and stop polling', async ({ page, }) => { const state = await setup(page); @@ -450,224 +402,45 @@ test('workspace change resets selection and ignores an old polling response', as release = resolve; }); await open(page); - await (await selectAndConfirm(page)).click(); + await installButton(page).click(); await expect.poll(() => state.polls).toBe(1); await page.evaluate(async () => { const path = '/src/app/infra/http/currentWorkspaceStore.ts'; const store = await import(path); - const old = store.getCurrentWorkspaceSnapshot(); - store.setCurrentWorkspaceSnapshot({ ...old, placement_generation: 2 }); + const current = store.getCurrentWorkspaceSnapshot(); + store.setCurrentWorkspaceSnapshot({ ...current, placement_generation: 2 }); }); + release(); await expect( page.getByRole('dialog', { name: 'Pipeline migration' }), ).toHaveCount(0); - release(); - await page - .getByRole('button', { name: 'Review migration', exact: true }) - .click(); - const dialog = page.getByRole('dialog', { name: 'Pipeline migration' }); - await expect( - dialog.getByRole('checkbox', { name: 'Legacy one', exact: true }), - ).not.toBeChecked(); - await expect(dialog.getByTestId('migration-result-one')).toHaveCount(0); expect(state.posts).toHaveLength(1); }); -test('at most fifty rows can be selected in the bounded dialog', async ({ - page, -}) => { - test.setTimeout(60_000); - const state = await setup(page); - state.items = Array.from({ length: 51 }, (_, index) => row(String(index))); - const dialog = await open(page); - for (let index = 0; index < 50; index++) - await dialog - .getByRole('checkbox', { name: `Legacy ${index}`, exact: true }) - .check(); - await expect( - dialog.getByRole('checkbox', { name: 'Legacy 50', exact: true }), - ).toBeDisabled(); - await expect(dialog.getByText('50 selected (maximum 50)')).toBeVisible(); - const bounds = await dialog.boundingBox(); - expect(bounds!.y).toBeGreaterThanOrEqual(0); - expect(bounds!.y + bounds!.height).toBeLessThanOrEqual( - page.viewportSize()!.height, - ); - expect(state.posts).toEqual([]); -}); - -test('all non-ready preview states are unselectable and activation is not reported as migrated', async ({ - page, -}) => { - const state = await setup(page); - state.items = [ - row('one'), - row('two', 'activation_pending'), - row('three', 'already_current'), - row('four', 'not_legacy'), - ]; - state.results = [ - { pipeline_uuid: 'one', state: 'activation_pending', code: null }, - ]; - const dialog = await open(page); - for (const name of ['Legacy two', 'Legacy three', 'Legacy four']) +for (const [language, title, review, action] of [ + ['zh-Hans', '流水线迁移', '检查迁移', '自动安装插件并迁移'], + [ + 'ja-JP', + 'パイプライン移行', + '移行を確認', + 'プラグインを自動インストールして移行', + ], +]) { + test(`localized automatic assistant (${language})`, async ({ page }) => { + await setup(page); + await page.addInitScript( + (locale) => localStorage.setItem('langbot_language', locale), + language, + ); + await page.goto('/home/pipelines'); + await page.getByRole('button', { name: review, exact: true }).click(); + const dialog = page.getByRole('dialog', { name: title }); await expect( - dialog.getByRole('checkbox', { name, exact: true }), - ).toBeDisabled(); - await (await selectAndConfirm(page)).click(); - await expect(dialog.getByTestId('migration-result-one')).toContainText( - 'Activation pending', - ); - await expect(dialog.getByTestId('migration-result-one')).not.toContainText( - 'Migrated', - ); -}); - -test('stale per-item result requires refreshed selection, never silently retries', async ({ - page, -}) => { - const state = await setup(page); - state.results = [ - { pipeline_uuid: 'one', state: 'stale', code: 'stale_preview' }, - ]; - const dialog = await open(page); - await (await selectAndConfirm(page)).click(); - await expect(dialog.getByTestId('migration-result-one')).toContainText( - 'Stale preview', - ); - await expect( - dialog.getByRole('button', { name: 'Migrate selected', exact: true }), - ).toBeDisabled(); - await dialog.getByRole('button', { name: 'Refresh preview' }).click(); - await expect( - dialog.getByRole('checkbox', { name: 'Legacy one', exact: true }), - ).not.toBeChecked(); - expect(state.posts).toHaveLength(1); -}); - -test('late preview from a previous workspace cannot populate the new workspace', async ({ - page, -}) => { - const state = await setup(page); - const dialog = await open(page); - let release!: () => void; - state.holdPreview = new Promise((resolve) => { - release = resolve; + dialog.getByRole('button', { name: action, exact: true }), + ).toBeEnabled(); + await expect(dialog).not.toContainText('pipelineMigration.'); }); - const before = state.previews; - await dialog.getByRole('button', { name: 'Refresh preview' }).click(); - await expect.poll(() => state.previews).toBeGreaterThan(before); - state.items = [row('other')]; - state.holdPreview = null; - await page.evaluate(async () => { - const storePath = '/src/app/infra/http/currentWorkspaceStore.ts'; - const contextPath = '/src/app/infra/http/workspaceContext.ts'; - const store = await import(storePath); - const context = await import(contextPath); - const old = store.getCurrentWorkspaceSnapshot(); - context.setActiveWorkspaceUuid('workspace-other'); - store.setCurrentWorkspaceSnapshot({ - ...old, - workspace: { ...old.workspace, uuid: 'workspace-other' }, - }); - }); - release(); - await page - .getByRole('button', { name: 'Review migration', exact: true }) - .click(); - await expect( - dialog.getByRole('checkbox', { name: 'Legacy other', exact: true }), - ).not.toBeChecked(); - await expect( - dialog.getByRole('checkbox', { name: 'Legacy one', exact: true }), - ).toHaveCount(0); - expect(state.posts).toEqual([]); -}); - -test('late execute admission after a workspace switch never polls in the new scope', async ({ - page, -}) => { - const state = await setup(page); - let release!: () => void; - state.holdExecute = new Promise((resolve) => { - release = resolve; - }); - await open(page); - await (await selectAndConfirm(page)).click(); - await expect.poll(() => state.posts.length).toBe(1); - await page.evaluate(async () => { - const path = '/src/app/infra/http/currentWorkspaceStore.ts'; - const store = await import(path); - const old = store.getCurrentWorkspaceSnapshot(); - store.setCurrentWorkspaceSnapshot({ ...old, placement_generation: 2 }); - }); - release(); - await page - .getByRole('button', { name: 'Review migration', exact: true }) - .click(); - await expect( - page.getByRole('checkbox', { name: 'Legacy one', exact: true }), - ).not.toBeChecked(); - expect(state.polls).toBe(0); -}); - -test('network loss during execute is an unknown outcome with read-only refresh', async ({ - page, -}) => { - const state = await setup(page); - await page.route(`**${root}/execute`, (route) => { - state.posts.push(route.request().postDataJSON()); - return route.abort('connectionreset'); - }); - const dialog = await open(page); - const before = state.previews; - await (await selectAndConfirm(page)).click(); - await expect( - dialog.getByText( - 'Task observation lost. Its outcome is unknown; refresh the preview before any further action.', - ), - ).toBeVisible(); - await expect.poll(() => state.previews).toBeGreaterThan(before); - expect(state.posts).toHaveLength(1); -}); - -test('legacy data returned on the editor second read cannot hydrate defaults or autosave', async ({ - page, -}) => { - const state = await setup(page, { current: true }); - let reads = 0; - await page.route('**/api/v1/pipelines/one', async (route) => { - if (route.request().method() !== 'GET') - state.writes.push(route.request().postDataJSON()); - reads++; - await reply(route, { - pipeline: { - uuid: 'one', - name: 'Legacy one', - description: '', - emoji: '⚙️', - config: - reads <= 2 - ? { - ai: { - runner: { id: 'plugin:langbot-team/LocalAgent/default' }, - }, - } - : { ai: { runner: { runner: 'local-agent' } } }, - }, - }); - }); - await page.goto('/home/pipelines?id=one'); - await expect( - page.getByText( - 'Legacy configuration is read-only until migration. Save and debug are unavailable to prevent implicit conversion.', - ), - ).toBeVisible(); - await expect(page.getByRole('tab', { name: 'AI', exact: true })).toHaveCount( - 0, - ); - expect(state.writes).toEqual([]); -}); +} async function actualAgentRoute(page: Page) { const state = await setup(page); @@ -698,64 +471,6 @@ async function clickSidebarPipeline(page: Page) { await expect(page).toHaveURL(/\/home\/agents\?id=one$/); } -test('SPEC sidebar navigation discovers migration and completion refreshes the actual detail', async ({ - page, -}) => { - const { state, listReads } = await actualAgentRoute(page); - await page.goto('/home'); - await clickSidebarPipeline(page); - await expect( - page.getByText( - 'Legacy configuration is read-only until migration. Save and debug are unavailable to prevent implicit conversion.', - ), - ).toBeVisible(); - const review = page.getByRole('button', { - name: 'Review migration', - exact: true, - }); - await expect(review).toHaveCount(1); - await review.click(); - const dialog = page.getByRole('dialog', { name: 'Pipeline migration' }); - const before = listReads(); - await (await selectAndConfirm(page)).click(); - await expect( - dialog.getByText('Task finished. Check each pipeline result below.'), - ).toBeVisible(); - await dialog.getByRole('button', { name: 'Cancel', exact: true }).click(); - await expect( - page.getByRole('tab', { name: 'AI', exact: true }), - ).toBeVisible(); - await expect.poll(listReads).toBeGreaterThan(before); - expect(state.polls).toBe(1); - expect(state.posts).toHaveLength(1); -}); - -test('SPEC agents list exposes one assistant that survives list to detail navigation', async ({ - page, -}) => { - const { state } = await actualAgentRoute(page); - await page.goto('/home/agents'); - const review = page.getByRole('button', { - name: 'Review migration', - exact: true, - }); - await expect(review).toHaveCount(1); - await review.click(); - await (await selectAndConfirm(page)).click(); - const dialog = page.getByRole('dialog', { name: 'Pipeline migration' }); - await expect(dialog.getByTestId('migration-result-one')).toContainText( - 'Migrated', - ); - await dialog.getByRole('button', { name: 'Cancel', exact: true }).click(); - await clickSidebarPipeline(page); - await review.click(); - await expect(dialog.getByTestId('migration-result-one')).toContainText( - 'Migrated', - ); - expect(state.polls).toBe(1); - expect(state.posts).toHaveLength(1); -}); - test('SPEC canonical empty current detail allows selecting and saving a runner', async ({ page, }) => { @@ -837,174 +552,3 @@ test('SPEC actual legacy on second read remains guarded on the sidebar route', a ).toHaveCount(0); expect(state.writes).toEqual([]); }); - -for (const defect of [ - 'extra', - 'wrong-result', - 'missing', - 'duplicate', - 'wrong-task', - 'wrong-kind', - 'missing-failed', - 'extra-running', -] as const) { - test(`SPEC task identity mismatch ${defect} loses observation without subset success or retry`, async ({ - page, - }) => { - const state = await setup(page); - const migrated = { pipeline_uuid: 'one', state: 'migrated', code: null }; - const outcomes = - defect === 'missing' || defect === 'missing-failed' - ? [] - : defect === 'wrong-result' - ? [{ ...migrated, pipeline_uuid: 'other' }] - : defect === 'duplicate' - ? [migrated, migrated] - : defect === 'extra' || defect === 'extra-running' - ? [ - migrated, - { ...migrated, pipeline_uuid: 'other', state: 'failed' }, - ] - : [migrated]; - await page.route('**/api/v1/system/tasks/411', async (route) => { - state.polls++; - await reply(route, { - id: defect === 'wrong-task' ? 999 : 411, - runtime: { - done: defect !== 'extra-running', - exception: defect === 'missing-failed' ? 'failure' : null, - }, - task_context: { - metadata: { - kind: - defect === 'wrong-kind' ? 'plugin_install' : 'pipeline_migration', - results: outcomes, - }, - }, - }); - }); - const dialog = await open(page); - await (await selectAndConfirm(page)).click(); - await expect( - dialog.getByText( - 'Task observation lost. Its outcome is unknown; refresh the preview before any further action.', - ), - ).toBeVisible(); - await expect( - dialog.getByText('Task finished. Check each pipeline result below.'), - ).toHaveCount(0); - await expect(dialog.getByTestId('migration-result-one')).not.toContainText( - 'Migrated', - ); - await expect( - dialog.getByRole('button', { name: 'Migrate selected', exact: true }), - ).toBeDisabled(); - await page.waitForTimeout(1100); - expect(state.posts).toHaveLength(1); - expect(state.polls).toBe(1); - }); -} - -test('SPEC actual agents workspace change discards old polling and selection', async ({ - page, -}) => { - const { state } = await actualAgentRoute(page); - let release!: () => void; - state.holdPoll = new Promise((resolve) => { - release = resolve; - }); - await page.goto('/home/agents'); - await page - .getByRole('button', { name: 'Review migration', exact: true }) - .click(); - await (await selectAndConfirm(page)).click(); - await expect.poll(() => state.polls).toBe(1); - await page.evaluate(async () => { - const path = '/src/app/infra/http/currentWorkspaceStore.ts'; - const store = await import(path); - const old = store.getCurrentWorkspaceSnapshot(); - store.setCurrentWorkspaceSnapshot({ ...old, placement_generation: 2 }); - }); - const dialog = page.getByRole('dialog', { name: 'Pipeline migration' }); - await expect(dialog).toHaveCount(0); - release(); - await page - .getByRole('button', { name: 'Review migration', exact: true }) - .click(); - await expect( - dialog.getByRole('checkbox', { name: 'Legacy one', exact: true }), - ).not.toBeChecked(); - await expect(dialog.getByTestId('migration-result-one')).toHaveCount(0); - await page.waitForTimeout(1100); - expect(state.polls).toBe(1); - expect(state.posts).toHaveLength(1); -}); - -test('completion specific migration warnings explain changed defaults without upstream text', async ({ - page, -}) => { - const state = await setup(page); - state.items[0].warnings = [ - { code: 'local.context_defaults', field: 'ai.local-agent.max-round' }, - { code: 'dify.timeout_default' }, - { code: 'secret-upstream-text' }, - ]; - const dialog = await open(page); - await expect(dialog).toContainText( - 'new context budget and summarization defaults', - ); - await expect(dialog).toContainText('30-second request timeout'); - await expect(dialog).toContainText('Review this setting before migration.'); - await expect(dialog).not.toContainText('secret-upstream-text'); - expect(state.posts).toHaveLength(0); -}); - -test('completion activation retry requires a fresh token, selection and explicit confirmation', async ({ - page, -}) => { - const state = await setup(page); - state.items = [ - { ...row('one', 'activation_pending'), preview_token: 'activation-one' }, - ]; - const dialog = await open(page); - expect(state.posts).toHaveLength(0); - await expect( - dialog.getByRole('checkbox', { name: 'Legacy one', exact: true }), - ).toBeEnabled(); - const submit = await selectAndConfirm(page); - await submit.click(); - await expect(dialog.getByTestId('migration-result-one')).toContainText( - 'Migrated', - ); - expect(state.posts).toEqual([ - { - confirmed: true, - items: [{ pipeline_uuid: 'one', preview_token: 'activation-one' }], - }, - ]); - await page.waitForTimeout(1100); - expect(state.posts).toHaveLength(1); - expect(state.writes).toHaveLength(0); -}); - -for (const [language, title, review] of [ - ['zh-Hans', '流水线迁移', '检查迁移'], - ['ja-JP', 'パイプライン移行', '移行を確認'], -]) { - test(`localized migration dialog (${language})`, async ({ page }) => { - await setup(page); - await page.addInitScript( - (locale) => localStorage.setItem('langbot_language', locale), - language, - ); - await page.goto('/home/pipelines'); - await page.getByRole('button', { name: review, exact: true }).click(); - const dialog = page.getByRole('dialog', { name: title }); - await expect(dialog).toBeVisible(); - await expect(dialog).not.toContainText('pipelineMigration.'); - await expect(dialog).not.toContainText('Migrate selected'); - await page.screenshot({ - path: `test-results/pipeline-migration-${language}.png`, - }); - }); -}