diff --git a/skills/skills/langbot-mcp-ops/SKILL.md b/skills/skills/langbot-mcp-ops/SKILL.md index ec47b8bb1..c1c80703c 100644 --- a/skills/skills/langbot-mcp-ops/SKILL.md +++ b/skills/skills/langbot-mcp-ops/SKILL.md @@ -75,6 +75,13 @@ create another Slack application; inspect the returned `app_id` first. The tools wrap the LangBot service layer. Current tools (v1): +`create_pipeline` and HTTP `POST /api/v1/pipelines` preserve a supplied `config` +and `extensions_preferences`; omit `config` to use installed Runner defaults. +Selecting `ai.runner.id` never grants extension access: an empty `plugins` list +with `enable_all_plugins=false` still denies that plugin's Tool, EventListener +and Command components, while allowing its selected Runner. Editing and copying +a pipeline retain these independent settings. + | Tool | Purpose | | --- | --- | | `get_system_info` | Version, edition, instance id | @@ -105,6 +112,13 @@ already have a default pipeline. ## How to use +Pipeline plugin extension switches authorize only EventListener, Tool and Command +components. Select the Runner independently in AI settings. An empty extension +allowlist does not prevent saving, copying or running that Runner. Never enable +all plugins or add its plugin to the allowlist as a Runner workaround: that also +authorizes its other components. The Runner plugin must still be installed and +enabled in the authenticated Workspace, with an available Plugin Runtime. + 1. Get an API key (web UI key, or set `api.global_api_key` in config.yaml). 2. Point your MCP client at `http://:5300/mcp` with the key header. 3. Call `get_system_info` to confirm connectivity. diff --git a/src/langbot/pkg/agent/runner/registry.py b/src/langbot/pkg/agent/runner/registry.py index 3ef6319cf..9298613db 100644 --- a/src/langbot/pkg/agent/runner/registry.py +++ b/src/langbot/pkg/agent/runner/registry.py @@ -14,7 +14,7 @@ from ...api.http.context import ExecutionContext from ...api.http.service.tenant import TenantContext from .descriptor import RunnerDescriptor from .id import parse_runner_id, format_runner_id -from .errors import RunnerNotFoundError, RunnerNotAuthorizedError +from .errors import RunnerNotFoundError class RunnerRegistry: @@ -229,14 +229,13 @@ class RunnerRegistry: Args: runner_id: Runner ID to lookup - bound_plugins: Optional bound plugins filter + bound_plugins: Legacy pipeline extension filter; does not authorize Runners. Returns: RunnerDescriptor Raises: RunnerNotFoundError: If runner not found - RunnerNotAuthorizedError: If runner not in bound plugins """ # Parse and validate runner ID format try: @@ -259,11 +258,9 @@ class RunnerRegistry: if descriptor is None: raise RunnerNotFoundError(runner_id) - # Check authorization - if bound_plugins is not None: - plugin_id = descriptor.get_plugin_id() - if plugin_id not in bound_plugins: - raise RunnerNotAuthorizedError(runner_id, bound_plugins) + # A selected Runner is independent of the pipeline extension allowlist. + # Installation enablement and Workspace scope are enforced by the connector + # again at invocation; never widen the extension permissions on the query. return descriptor diff --git a/src/langbot/pkg/api/http/service/pipeline.py b/src/langbot/pkg/api/http/service/pipeline.py index 22a1c86c0..4316ee864 100644 --- a/src/langbot/pkg/api/http/service/pipeline.py +++ b/src/langbot/pkg/api/http/service/pipeline.py @@ -189,7 +189,8 @@ class PipelineService: pipeline_data['stages'] = default_stage_order.copy() pipeline_data['is_default'] = default - pipeline_data['config'] = await self.get_default_pipeline_config(context) + if 'config' not in pipeline_data: + pipeline_data['config'] = await self.get_default_pipeline_config(context) RunnerConfigResolver.validate_pipeline_config(pipeline_data['config']) # Ensure extensions_preferences is set with enable_all_plugins and enable_all_mcp_servers=True by default diff --git a/src/langbot/pkg/api/mcp/server.py b/src/langbot/pkg/api/mcp/server.py index 527bd00d8..d5d007fcf 100644 --- a/src/langbot/pkg/api/mcp/server.py +++ b/src/langbot/pkg/api/mcp/server.py @@ -132,7 +132,13 @@ class LangBotMCPServer: await ap.pipeline_service.get_pipeline_extensions(_authorized(Permission.RESOURCE_VIEW), pipeline_uuid) ) - @mcp.tool(description='Replace all Pipeline extension bindings. All binding lists and switches are required.') + @mcp.tool( + description=( + 'Replace all Pipeline extension bindings. All binding lists and switches are required. ' + 'Plugin switches control EventListener, Tool and Command only, not the independently selected Runner. ' + 'Do not add a Runner plugin here just to run it; installation, global enablement and Workspace access still apply.' + ) + ) async def update_pipeline_extensions( pipeline_uuid: str, bound_plugins: list[dict], @@ -394,7 +400,8 @@ class LangBotMCPServer: @mcp.tool( description=( 'Create a pipeline. `pipeline_data` matches the LangBot POST ' - '/api/v1/pipelines body. Returns the new pipeline UUID.' + '/api/v1/pipelines body. Supplied config and extension preferences are preserved; ' + 'omitting config uses installed Runner defaults. Returns the new pipeline UUID.' ) ) async def create_pipeline(pipeline_data: dict) -> str: diff --git a/tests/e2e/test_runner_plugin_runtime.py b/tests/e2e/test_runner_plugin_runtime.py index f4f1de698..128d96368 100644 --- a/tests/e2e/test_runner_plugin_runtime.py +++ b/tests/e2e/test_runner_plugin_runtime.py @@ -7,6 +7,9 @@ loads a deterministic Runner plugin through the real SDK Plugin Runtime. from __future__ import annotations import shutil +import json +import os +import signal import socket import sqlite3 import subprocess @@ -31,15 +34,18 @@ QA_RUNNER_ID = 'plugin:e2e/agent-runner-qa/default' @pytest.fixture(scope='session') def runner_e2e_port(): """Port for the Runner plugin-runtime E2E process.""" - return 15310 + return _free_port() @pytest.fixture(scope='session') def runner_e2e_tmpdir(): """Create temporary directory for Runner E2E testing.""" - tmpdir = Path(tempfile.mkdtemp(prefix='langbot_runner_e2e_')) + root = find_project_root() / '.qa' / 'lang54' + root.mkdir(parents=True, exist_ok=True) + tmpdir = Path(tempfile.mkdtemp(prefix='runner-runtime-', dir=root)) yield tmpdir - shutil.rmtree(tmpdir, ignore_errors=True) + if not os.environ.get('LANGBOT_KEEP_RUNNER_E2E'): + shutil.rmtree(tmpdir, ignore_errors=True) def _write_qa_runner_plugin(plugin_root: Path) -> None: @@ -74,6 +80,15 @@ def _write_qa_runner_plugin(plugin_root: Path) -> None: Runner: fromDirs: - path: components/runner/ + Tool: + fromDirs: + - path: components/tools/ + Command: + fromDirs: + - path: components/commands/ + EventListener: + fromDirs: + - path: components/event_listener/ pages: [] execution: python: @@ -115,10 +130,19 @@ def _write_qa_runner_plugin(plugin_root: Path) -> None: zh_Hans: 回显输入并验证运行级状态 API。 spec: usages: [agent] - config: [] + config: + - name: prefix + type: string + label: + en_US: Echo prefix + zh_Hans: 回显前缀 + default: 'e2e echo' + required: false capabilities: streaming: false - permissions: {} + tool_calling: true + permissions: + tools: [detail, call] execution: python: path: default.py @@ -134,6 +158,8 @@ def _write_qa_runner_plugin(plugin_root: Path) -> None: from __future__ import annotations from typing import AsyncGenerator + import json + from pathlib import Path from langbot_plugin.api.definition.components.runner.runner import Runner from langbot_plugin.api.entities.builtin.runner.context import RunnerContext @@ -144,9 +170,26 @@ def _write_qa_runner_plugin(plugin_root: Path) -> None: class DefaultRunner(Runner): async def run(self, ctx: RunnerContext) -> AsyncGenerator[RunnerResult, None]: text = ctx.input.to_text() + content = f'{ctx.config.get("prefix", "e2e echo")}: {text}' + if text.startswith('boundary probe'): + tools = await ctx.get_available_tools() + names = [item['name'] for item in tools] + tool_name = next((name for name in names if 'qa_boundary_tool' in name), 'qa_boundary_tool') + try: + result = await ctx.call_tool(tool_name, {}) + denied = 'QA_TOOL_EXECUTED' not in json.dumps(result) + except Exception as exc: + result = str(exc) + denied = True + content = json.dumps({ + 'tool_result': str(result), + 'tool_names': names, + 'tool_denied': denied, + 'listener_hits': getattr(self.plugin, 'boundary_listener_hits', 0), + }) yield RunnerResult.message_completed( ctx.run_id, - Message(role='assistant', content=f'e2e echo: {text}'), + Message(role='assistant', content=content), ) yield RunnerResult.state_updated( ctx.run_id, @@ -161,6 +204,75 @@ def _write_qa_runner_plugin(plugin_root: Path) -> None: encoding='utf-8', ) + import yaml + + components = { + 'tools': ( + 'Tool', + 'qa_boundary_tool', + 'BoundaryTool', + {'parameters': {}, 'llm_prompt': 'QA boundary tool'}, + """ + from langbot_plugin.api.definition.components.tool.tool import Tool + + class BoundaryTool(Tool): + async def call(self, params, session, query_id): + return {'result': 'QA_TOOL_EXECUTED'} + """, + ), + 'commands': ( + 'Command', + 'qa_boundary_command', + 'BoundaryCommand', + {}, + """ + from langbot_plugin.api.definition.components.command.command import Command + from langbot_plugin.api.entities.builtin.command.context import CommandReturn + + class BoundaryCommand(Command): + async def initialize(self): + await super().initialize() + @self.subcommand('') + async def handle(self, ctx): + yield CommandReturn(text='QA_COMMAND_EXECUTED') + """, + ), + 'event_listener': ( + 'EventListener', + 'boundary', + 'BoundaryListener', + {}, + """ + from pathlib import Path + from langbot_plugin.api.definition.components.common.event_listener import EventListener + from langbot_plugin.api.entities import events + + class BoundaryListener(EventListener): + async def initialize(self): + await super().initialize() + @self.handler(events.PersonNormalMessageReceived) + async def handle(ctx): + self.plugin.boundary_listener_hits = getattr(self.plugin, 'boundary_listener_hits', 0) + 1 + """, + ), + } + for directory, (kind, name, attr, spec, source) in components.items(): + target = plugin_root / 'components' / directory + target.mkdir(parents=True, exist_ok=True) + (target / f'{name}.yaml').write_text( + yaml.safe_dump( + { + 'apiVersion': 'v1', + 'kind': kind, + 'metadata': {'name': name, 'label': {'en_US': name}, 'description': {'en_US': name}}, + 'spec': spec, + 'execution': {'python': {'path': f'{name}.py', 'attr': attr}}, + } + ), + encoding='utf-8', + ) + (target / f'{name}.py').write_text(textwrap.dedent(source), encoding='utf-8') + processor_dir = runner_dir (processor_dir / 'welcome.yaml').write_text( textwrap.dedent(""" @@ -237,6 +349,12 @@ def runner_e2e_config_path(runner_e2e_tmpdir, runner_e2e_port, runner_runtime_po config['plugin']['enable_marketplace'] = False config['box']['enabled'] = False config['system']['jwt']['secret'] = 'e2e-agent-runner-secret-key' + adapters = find_project_root() / 'src' / 'langbot' / 'pkg' / 'platform' / 'sources' + config['system']['disabled_adapters'] = [ + yaml.safe_load(path.read_text(encoding='utf-8'))['metadata']['name'] + for path in sorted(adapters.glob('*.yaml')) + if path.stem != 'websocket' + ] with open(config_path, 'w', encoding='utf-8') as f: yaml.safe_dump(config, f, default_flow_style=False) @@ -275,12 +393,14 @@ def runner_runtime_process(runner_e2e_tmpdir, runner_runtime_ports): start_new_session=True, ) yield proc - proc.terminate() try: + os.killpg(proc.pid, signal.SIGTERM) proc.wait(timeout=5) except subprocess.TimeoutExpired: - proc.kill() + os.killpg(proc.pid, signal.SIGKILL) proc.wait() + except ProcessLookupError: + pass stdout_file.close() stderr_file.close() @@ -298,8 +418,9 @@ def runner_langbot_process( project_root=project_root, work_dir=runner_e2e_tmpdir, port=runner_e2e_port, - timeout=180, - debug=True, + timeout=360, + debug=False, + collect_coverage=False, cli_args=['--standalone-runtime'], ) @@ -565,3 +686,140 @@ def test_event_processor_real_runtime_logs_actions_and_instance_isolation( foreign = client.get(f'/api/v1/agents/{created[1]}/runs/{run["run_id"]}/events', headers=headers) assert foreign.status_code == 400 assert client.get(f'/api/v1/agents/{created[1]}/runs', headers=headers).json()['data']['items'] == [] + + +def _create_boundary_pipeline(client, token): + headers = {'Authorization': f'Bearer {token}'} + created = client.post( + '/api/v1/pipelines', headers=headers, json={'name': 'Runner boundary QA', 'description': ''} + ).json() + assert created['code'] == 0, created + pipeline_id = created['data']['uuid'] + config = client.get(f'/api/v1/pipelines/{pipeline_id}', headers=headers).json()['data']['pipeline']['config'] + config['ai']['runner'] = {'id': QA_RUNNER_ID, 'expire-time': 0} + config['ai']['runner_config'] = {QA_RUNNER_ID: {'prefix': 'e2e echo', 'hidden': {'keep': [None, False, ' raw ']}}} + saved = client.put(f'/api/v1/pipelines/{pipeline_id}', headers=headers, json={'config': config}).json() + assert saved['code'] == 0, saved + return pipeline_id + + +def _pipeline_failure_diagnostics(directory, pipeline_id): + with sqlite3.connect(directory / 'data' / 'langbot.db') as connection: + messages = connection.execute( + 'SELECT status, level, runner_name FROM monitoring_messages WHERE pipeline_id = ?', + (pipeline_id,), + ).fetchall() + calls = connection.execute( + 'SELECT status, error_message FROM monitoring_llm_calls WHERE pipeline_id = ?', + (pipeline_id,), + ).fetchall() + runs = connection.execute( + 'SELECT status, status_reason FROM agent_run WHERE binding_id = ?', + (pipeline_id,), + ).fetchall() + errors = connection.execute( + 'SELECT error_type, error_message, stack_trace FROM monitoring_errors WHERE pipeline_id = ?', + (pipeline_id,), + ).fetchall() + diagnostic = {'messages': messages, 'calls': calls, 'runs': runs, 'errors': errors} + target = find_project_root() / '.qa' / 'lang54' / 'pipeline-failure.json' + target.write_text(json.dumps(diagnostic, indent=2), encoding='utf-8') + return diagnostic + + +def test_mixed_plugin_runner_does_not_authorize_extensions(runner_client, runner_e2e_tmpdir): + token = _init_and_auth(runner_client) + _ensure_qa_plugin(runner_client, token, runner_e2e_tmpdir / 'agent-runner-qa.zip') + pipeline_id = _create_boundary_pipeline(runner_client, token) + headers = {'Authorization': f'Bearer {token}'} + + def run(text): + response = runner_client.post( + f'/api/v1/pipelines/{pipeline_id}/run', headers=headers, json={'message': text} + ).json() + assert response['code'] == 0, response + if response['data']['status'] != 'completed': + pytest.fail( + f'{response}\nPersisted diagnostics: {_pipeline_failure_diagnostics(runner_e2e_tmpdir, pipeline_id)}' + ) + return response['data']['reply'] + + enabled = json.loads(run('boundary probe enabled')) + assert enabled['listener_hits'] > 0, enabled + assert any('qa_boundary_tool' in name for name in enabled['tool_names']), enabled + assert enabled['tool_denied'] is False, enabled + assert 'QA_COMMAND_EXECUTED' in run('!qa_boundary_command') + last_enabled = json.loads(run('boundary probe before disabling')) + disabled = runner_client.put( + f'/api/v1/pipelines/{pipeline_id}/extensions', + headers=headers, + json={ + 'enable_all_plugins': False, + 'bound_plugins': [], + 'enable_all_mcp_servers': False, + 'bound_mcp_servers': [], + 'enable_all_skills': False, + 'bound_skills': [], + }, + ).json() + assert disabled['code'] == 0, disabled + restricted = json.loads(run('boundary probe disabled')) + assert restricted['listener_hits'] == last_enabled['listener_hits'], restricted + assert not any('qa_boundary_tool' in name for name in restricted['tool_names']), restricted + assert restricted['tool_denied'] is True, restricted + command_reply = run('!qa_boundary_command') + assert 'QA_COMMAND_EXECUTED' not in command_reply + assert 'qa_boundary_command' in command_reply + + +def test_chromium_pipeline_save_copy_execute_real_runtime(runner_client, runner_e2e_tmpdir): + token = _init_and_auth(runner_client) + _ensure_qa_plugin(runner_client, token, runner_e2e_tmpdir / 'agent-runner-qa.zip') + pipeline_id = _create_boundary_pipeline(runner_client, token) + headers = {'Authorization': f'Bearer {token}'} + response = runner_client.put( + f'/api/v1/pipelines/{pipeline_id}/extensions', + headers=headers, + json={ + 'enable_all_plugins': False, + 'bound_plugins': [], + 'enable_all_mcp_servers': False, + 'bound_mcp_servers': [], + 'enable_all_skills': False, + 'bound_skills': [], + }, + ).json() + assert response['code'] == 0, response + env = os.environ.copy() + env.update( + { + 'LANGBOT_RUNNER_API_URL': str(runner_client.base_url).rstrip('/'), + 'LANGBOT_RUNNER_WEB_PORT': str(_free_port()), + 'LANGBOT_RUNNER_TOKEN': token, + 'LANGBOT_RUNNER_PIPELINE_ID': pipeline_id, + 'LANGBOT_RUNNER_BROWSER_OUTPUT': str(runner_e2e_tmpdir / 'browser-results'), + } + ) + browser = subprocess.Popen( + ['node', 'node_modules/@playwright/test/cli.js', 'test', '--config=playwright.runner-runtime.config.ts'], + cwd=find_project_root() / 'web', + env=env, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + start_new_session=True, + ) + try: + stdout, stderr = browser.communicate(timeout=300) + assert browser.returncode == 0, f'Chromium real-stack E2E:\n{stdout}\n{stderr}' + print(stdout) + finally: + try: + os.killpg(browser.pid, signal.SIGTERM) + except ProcessLookupError: + pass + try: + browser.wait(timeout=5) + except subprocess.TimeoutExpired: + os.killpg(browser.pid, signal.SIGKILL) + browser.wait(timeout=5) diff --git a/tests/unit_tests/agent/test_registry.py b/tests/unit_tests/agent/test_registry.py index 025d23f74..cdcfcc4a6 100644 --- a/tests/unit_tests/agent/test_registry.py +++ b/tests/unit_tests/agent/test_registry.py @@ -8,7 +8,7 @@ import pytest from langbot.pkg.agent.runner.registry import RunnerRegistry from langbot.pkg.agent.runner.descriptor import RunnerDescriptor -from langbot.pkg.agent.runner.errors import RunnerNotFoundError, RunnerNotAuthorizedError +from langbot.pkg.agent.runner.errors import RunnerNotFoundError from langbot.pkg.api.http.context import ExecutionContext @@ -242,8 +242,9 @@ class TestRegistryGet: assert ap.plugin_connector.list_runners.await_count == 2 @pytest.mark.asyncio - async def test_get_runner_with_bound_plugins_filter(self): - """Get runner with bound plugins authorization.""" + @pytest.mark.parametrize('plugins', [None, [], ['langbot-team/LocalAgent']]) + async def test_get_runner_independent_of_extension_filter(self, plugins): + """Selecting a Runner must not grant or require extension authorization.""" ap = FakeApplication() registry = RunnerRegistry(ap) @@ -255,13 +256,13 @@ class TestRegistryGet: ) assert descriptor is not None - # Not authorized - plugin not in bound list - with pytest.raises(RunnerNotAuthorizedError): - await registry.get( - TEST_CONTEXT, - 'plugin:alice/my-agent/custom', - bound_plugins=['langbot-team/LocalAgent'], - ) + descriptor = await registry.get( + TEST_CONTEXT, + 'plugin:alice/my-agent/custom', + bound_plugins=plugins, + ) + assert descriptor.id == 'plugin:alice/my-agent/custom' + assert plugins in (None, [], ['langbot-team/LocalAgent']) class TestRegistryMetadataForPipeline: diff --git a/tests/unit_tests/agent/test_runner_boundary_fixture.py b/tests/unit_tests/agent/test_runner_boundary_fixture.py new file mode 100644 index 000000000..1c5472482 --- /dev/null +++ b/tests/unit_tests/agent/test_runner_boundary_fixture.py @@ -0,0 +1,39 @@ +import ast + +import yaml + +from langbot_plugin.api.entities.builtin.runner.manifest import RunnerManifest +from langbot_plugin.api.definition.components.manifest import ComponentManifest + +from tests.e2e.test_runner_plugin_runtime import _write_qa_runner_plugin + + +def test_real_runtime_fixture_contains_all_four_component_kinds(tmp_path): + _write_qa_runner_plugin(tmp_path) + manifest = yaml.safe_load((tmp_path / 'manifest.yaml').read_text()) + assert set(manifest['spec']['components']) == {'Runner', 'Tool', 'Command', 'EventListener'} + kinds = set() + for path in (tmp_path / 'components').rglob('*.yaml'): + raw = yaml.safe_load(path.read_text()) + component = ComponentManifest( + owner='e2e/agent-runner-qa', manifest=raw, rel_path=str(path.relative_to(tmp_path)) + ) + kinds.add(component.kind) + if component.kind == 'Runner': + spec = raw['spec'] + runner = RunnerManifest( + id=f'plugin:e2e/agent-runner-qa/{raw["metadata"]["name"]}', + name=raw['metadata']['name'], + label=raw['metadata']['label'], + usages=spec['usages'], + supported_event_patterns=spec.get('events') or ['*'], + capabilities=spec.get('capabilities') or {}, + permissions=spec.get('permissions') or {}, + config_schema=spec.get('config') or [], + ) + if path.stem == 'default': + assert runner.capabilities.tool_calling is True + assert set(runner.permissions.tools) == {'detail', 'call'} + assert kinds == {'Runner', 'Tool', 'Command', 'EventListener'} + for path in tmp_path.rglob('*.py'): + ast.parse(path.read_text(), filename=str(path)) diff --git a/tests/unit_tests/api/service/test_runner_extension_boundary.py b/tests/unit_tests/api/service/test_runner_extension_boundary.py new file mode 100644 index 000000000..34cb33f20 --- /dev/null +++ b/tests/unit_tests/api/service/test_runner_extension_boundary.py @@ -0,0 +1,124 @@ +"""Runner selection must survive restricted extension preferences on disk.""" + +from types import SimpleNamespace as NS +import json +from unittest.mock import AsyncMock, Mock + +import pytest +import sqlalchemy as sa + +from langbot.pkg.api.http.service.pipeline import PipelineService +from langbot.pkg.entity.persistence.pipeline import LegacyPipeline +from langbot.pkg.workspace.errors import WorkspaceNotFoundError +from tests.unit_tests.api.service.test_pipeline_migration import env, context, RID # noqa: F401 + + +@pytest.mark.asyncio +async def test_http_and_mcp_share_real_persistence_without_expanding_extensions(env, monkeypatch): # noqa: F811 + from quart import Quart + from langbot.pkg.api.http.controller.groups.pipelines.pipelines import PipelinesRouterGroup + from langbot.pkg.api.mcp.server import LangBotMCPServer + + monkeypatch.setattr('langbot.pkg.api.http.service.workspace_limits.workspace_limits', AsyncMock(return_value={})) + monkeypatch.setattr('langbot.pkg.api.mcp.server.get_request_context', context) + monkeypatch.setattr('langbot.pkg.api.mcp.server.get_workspace_language', AsyncMock(return_value='en_US')) + monkeypatch.setattr('langbot.pkg.api.http.controller.group.get_workspace_language', AsyncMock(return_value='en_US')) + env.ap.ver_mgr = NS(get_current_version=Mock(return_value='4.10')) + env.ap.pipeline_mgr.load_pipeline = AsyncMock() + env.ap.pipeline_mgr.remove_pipeline = AsyncMock() + env.ap.pipeline_service = PipelineService(env.ap) + app = Quart(__name__) + routes = PipelinesRouterGroup(env.ap, app) + monkeypatch.setattr(routes, '_authenticate_api_key', AsyncMock(return_value=context())) + monkeypatch.setattr(routes, '_record_operation', AsyncMock()) + await routes.initialize() + http = app.test_client() + mcp = LangBotMCPServer(env.ap) + headers = {'X-API-Key': 'isolated-test-key'} + config = { + 'ai': { + 'runner': {'id': RID, 'expire-time': 0}, + 'runner_config': {RID: {'unknown': {'keep': [None, False, ' raw ']}}}, + } + } + prefs = {'enable_all_plugins': False, 'plugins': [], 'enable_all_mcp_servers': False, 'mcp_servers': []} + async with env.engine.begin() as conn: + await conn.execute( + sa.update(LegacyPipeline) + .where(LegacyPipeline.uuid == 'one') + .values(config=config, extensions_preferences=prefs) + ) + response = await http.put('/api/v1/pipelines/one', headers=headers, json={'config': config}) + assert response.status_code == 200, await response.get_data(as_text=True) + assert (await response.get_json())['code'] == 0 + result = await mcp.mcp.call_tool('get_pipeline', {'pipeline_uuid': 'one'}) + saved = json.loads(result[0][0].text) + assert saved['config'] == config + assert saved['extensions_preferences'] == prefs + config['ai']['runner_config'][RID]['edited'] = 'via MCP' + result = await mcp.mcp.call_tool('update_pipeline', {'pipeline_uuid': 'one', 'pipeline_data': {'config': config}}) + assert json.loads(result[0][0].text) == {'ok': True} + copied_response = await http.post('/api/v1/pipelines/one/copy', headers=headers) + assert copied_response.status_code == 200, await copied_response.get_data(as_text=True) + copied = (await copied_response.get_json())['data']['uuid'] + for pipeline_id in ('one', copied): + response = await http.get(f'/api/v1/pipelines/{pipeline_id}', headers=headers) + saved = (await response.get_json())['data']['pipeline'] + assert saved['config'] == config + assert saved['extensions_preferences']['plugins'] == [] + assert saved['extensions_preferences']['enable_all_plugins'] is False + async with env.engine.connect() as conn: + row = ( + await conn.execute( + sa.select(LegacyPipeline.config, LegacyPipeline.extensions_preferences).where( + LegacyPipeline.uuid == pipeline_id + ) + ) + ).one() + assert row.config == config + assert row.extensions_preferences['plugins'] == [] + assert row.extensions_preferences['enable_all_plugins'] is False + body = {'name': 'HTTP creation', 'description': '', 'config': config, 'extensions_preferences': prefs} + response = await http.post('/api/v1/pipelines', headers=headers, json=body) + assert response.status_code == 200, await response.get_data(as_text=True) + http_id = (await response.get_json())['data']['uuid'] + body['name'] = 'MCP creation' + result = await mcp.mcp.call_tool('create_pipeline', {'pipeline_data': body}) + mcp_id = json.loads(result[0][0].text)['uuid'] + for pipeline_id in (http_id, mcp_id): + saved = await env.ap.pipeline_service.get_pipeline(context(), pipeline_id, include_secret=True) + assert saved['config'] == config + assert saved['extensions_preferences'] == prefs + + +@pytest.mark.asyncio +async def test_edit_and_copy_do_not_expand_extension_permissions(env, monkeypatch): # noqa: F811 + monkeypatch.setattr( + 'langbot.pkg.api.http.service.workspace_limits.workspace_limits', + AsyncMock(return_value={}), + ) + env.ap.ver_mgr = NS(get_current_version=Mock(return_value='4.10')) + env.ap.pipeline_mgr.load_pipeline = AsyncMock() + env.ap.pipeline_mgr.remove_pipeline = AsyncMock() + service = PipelineService(env.ap) + config = {'ai': {'runner': {'id': RID}, 'runner_config': {RID: {}}}} + prefs = {'enable_all_plugins': False, 'plugins': [], 'enable_all_mcp_servers': False, 'mcp_servers': []} + # Existing, pre-fix configuration: selected Runner with no extensions. + async with env.engine.begin() as conn: + await conn.execute( + sa.update(LegacyPipeline) + .where(LegacyPipeline.uuid == 'one') + .values( + config=config, + extensions_preferences=prefs, + ) + ) + await service.update_pipeline(context(), 'one', {'config': config, 'name': 'Edited'}) + copied = await service.copy_pipeline(context(), 'one') + for pid in ('one', copied): + saved = await service.get_pipeline(context(), pid, include_secret=True) + assert saved['config'] == config + assert saved['extensions_preferences']['plugins'] == [] + assert saved['extensions_preferences']['enable_all_plugins'] is False + with pytest.raises(WorkspaceNotFoundError): + await service.copy_pipeline(context(), 'foreign') diff --git a/tests/unit_tests/plugin/test_plugin_component_filtering.py b/tests/unit_tests/plugin/test_plugin_component_filtering.py index 9138c04a7..4a365208e 100644 --- a/tests/unit_tests/plugin/test_plugin_component_filtering.py +++ b/tests/unit_tests/plugin/test_plugin_component_filtering.py @@ -32,7 +32,8 @@ def configure_connector(connector) -> None: @pytest.mark.asyncio -async def test_plugin_list_filter_by_component_kinds(): +@pytest.mark.parametrize('independent_kind', ['KnowledgeEngine', 'Runner']) +async def test_plugin_list_filter_by_component_kinds(independent_kind): """Test that plugins can be filtered by component kinds.""" from langbot.pkg.plugin.connector import PluginRuntimeConnector @@ -70,7 +71,7 @@ async def test_plugin_list_filter_by_component_kinds(): } } }, - 'components': [{'manifest': {'manifest': {'kind': 'KnowledgeEngine', 'metadata': {'name': 'retriever1'}}}}], + 'components': [{'manifest': {'manifest': {'kind': independent_kind, 'metadata': {'name': 'retriever1'}}}}], }, { 'debug': False, @@ -107,7 +108,7 @@ async def test_plugin_list_filter_by_component_kinds(): } }, 'components': [ - {'manifest': {'manifest': {'kind': 'KnowledgeEngine', 'metadata': {'name': 'retriever2'}}}}, + {'manifest': {'manifest': {'kind': independent_kind, 'metadata': {'name': 'retriever2'}}}}, {'manifest': {'manifest': {'kind': 'Tool', 'metadata': {'name': 'tool2'}}}}, ], }, diff --git a/web/playwright.runner-runtime.config.ts b/web/playwright.runner-runtime.config.ts new file mode 100644 index 000000000..83f9c03b1 --- /dev/null +++ b/web/playwright.runner-runtime.config.ts @@ -0,0 +1,30 @@ +import { defineConfig, devices } from '@playwright/test'; + +const port = process.env.LANGBOT_RUNNER_WEB_PORT; +if (!port || !process.env.LANGBOT_RUNNER_API_URL) { + throw new Error( + 'Run through tests/e2e/test_runner_plugin_runtime.py with its isolated backend and SDK runtime', + ); +} + +export default defineConfig({ + testDir: './tests/e2e', + testMatch: 'runner-real-runtime.spec.ts', + workers: 1, + retries: 0, + timeout: 120_000, + reporter: 'list', + outputDir: process.env.LANGBOT_RUNNER_BROWSER_OUTPUT, + use: { + baseURL: `http://127.0.0.1:${port}`, + trace: 'retain-on-failure', + ...devices['Desktop Chrome'], + }, + webServer: { + command: `node node_modules/vite/bin/vite.js preview --host 127.0.0.1 --port ${port} --strictPort`, + url: `http://127.0.0.1:${port}`, + reuseExistingServer: false, + timeout: 60_000, + env: { VITE_API_PROXY_TARGET: process.env.LANGBOT_RUNNER_API_URL }, + }, +}); diff --git a/web/src/app/home/agents/components/RunnerSelect.tsx b/web/src/app/home/agents/components/RunnerSelect.tsx index 3953a8e70..0e9d9e83a 100644 --- a/web/src/app/home/agents/components/RunnerSelect.tsx +++ b/web/src/app/home/agents/components/RunnerSelect.tsx @@ -3,7 +3,7 @@ import { Bot, Puzzle, ExternalLink, Loader2, Store } from 'lucide-react'; import { useTranslation } from 'react-i18next'; import { toast } from 'sonner'; -import { getCloudServiceClientSync } from '@/app/infra/http'; +import { getCloudServiceClientSync, httpClient } from '@/app/infra/http'; import { useInstalledPluginIcon } from '@/app/infra/hooks/useInstalledPluginIcon'; import type { IDynamicFormItemOption } from '@/app/infra/entities/form/dynamic'; import type { PluginV4 } from '@/app/infra/entities/plugin'; @@ -233,6 +233,45 @@ export default function RunnerSelect({ useState({}); const [catalogLoading, setCatalogLoading] = useState(true); const [catalogError, setCatalogError] = useState(false); + const [runnerProblem, setRunnerProblem] = useState< + 'missing' | 'disabled' | 'runtime' | null + >(null); + const [diagnosing, setDiagnosing] = useState(false); + const diagnoseRunner = useCallback(async () => { + const pluginId = runnerPluginId(value); + if (!pluginId) return null; + const status = await httpClient.getPluginSystemStatus(); + if (!status.is_enable || !status.is_connected) return 'runtime' as const; + const { plugins } = await httpClient.getPlugins(); + const plugin = plugins.find((item) => { + const metadata = item.manifest.manifest.metadata; + return `${metadata.author}/${metadata.name}` === pluginId; + }); + if (!plugin) return 'missing' as const; + if (!plugin.enabled) return 'disabled' as const; + return options.some((option) => option.name === value) + ? null + : ('missing' as const); + }, [value, options]); + + useEffect(() => { + let cancelled = false; + setRunnerProblem(null); + setDiagnosing(true); + void diagnoseRunner() + .then((problem) => { + if (!cancelled) setRunnerProblem(problem); + }) + .catch(() => { + if (!cancelled) setRunnerProblem('runtime'); + }) + .finally(() => { + if (!cancelled) setDiagnosing(false); + }); + return () => { + cancelled = true; + }; + }, [diagnoseRunner]); const [pendingInstall, setPendingInstall] = useState(() => readPendingRunnerInstall(installScope), ); @@ -405,6 +444,8 @@ export default function RunnerSelect({ option={selectedOption} eventProcessor={usage === 'event'} /> + ) : value ? ( + {value} ) : ( + {!diagnosing && runnerProblem && ( +
+

{t(`agents.runnerDiagnosis.${runnerProblem}`)}

+ + {t('agents.runnerDiagnosis.repair')} + + +

{t('agents.runnerDiagnosis.draft')}

+
+ )} {installError && (

diff --git a/web/src/app/home/pipelines/components/pipeline-extensions/PipelineExtension.tsx b/web/src/app/home/pipelines/components/pipeline-extensions/PipelineExtension.tsx index 636bd5ced..2b7f99ce3 100644 --- a/web/src/app/home/pipelines/components/pipeline-extensions/PipelineExtension.tsx +++ b/web/src/app/home/pipelines/components/pipeline-extensions/PipelineExtension.tsx @@ -353,6 +353,9 @@ export default function PipelineExtension({

{/* Plugins Section */}
+

+ {t('pipelines.extensions.pluginScopeHint')} +

{t('pipelines.extensions.pluginsTitle')} diff --git a/web/src/app/home/pipelines/components/pipeline-form/PipelineFormComponent.tsx b/web/src/app/home/pipelines/components/pipeline-form/PipelineFormComponent.tsx index 309f33bb1..a401e41f7 100644 --- a/web/src/app/home/pipelines/components/pipeline-form/PipelineFormComponent.tsx +++ b/web/src/app/home/pipelines/components/pipeline-form/PipelineFormComponent.tsx @@ -723,6 +723,9 @@ const PipelineFormComponent = forwardRef< {extractI18nObject(stage.label)} + + {t('pipelines.extensions.runnerScopeHint')} + {stage.description && ( {extractI18nObject(stage.description)} diff --git a/web/src/i18n/locales/en-US.ts b/web/src/i18n/locales/en-US.ts index 05f9ee3dc..47f8dabe5 100644 --- a/web/src/i18n/locales/en-US.ts +++ b/web/src/i18n/locales/en-US.ts @@ -1611,6 +1611,16 @@ const enUS = { restoringRunnerInstall: 'Restoring the Runner plugin installation and waiting for the runner…', noInstalledRunners: 'No Runner extension is installed yet.', + runnerDiagnosis: { + missing: + 'The selected Runner or its plugin is missing. Install or restore the plugin.', + disabled: 'The selected Runner plugin is disabled. Enable it in Plugins.', + runtime: + 'The Plugin Runtime is unavailable or disabled. Restore its connection before running.', + repair: 'Manage plugins and runtime', + draft: + 'Repair opens in a new tab. Your Runner selection and unsaved draft stay here.', + }, installingRunner: 'Installing {{runner}}...', runnerInstallSuccess: '{{runner}} is installed and ready to select', selectedRunnerUnavailable: 'Selected runner is unavailable', @@ -2219,6 +2229,10 @@ const enUS = { addPlugin: 'Add Plugin', selectPlugins: 'Select Plugins', pluginsTitle: 'Plugins', + pluginScopeHint: + 'Only controls event listeners, tools and commands for this pipeline; does not affect the Runner selected in AI settings.', + runnerScopeHint: + 'Select the Runner independently here, regardless of extension plugin switches. Its plugin must still be installed and available.', mcpServersTitle: 'MCP Servers', mcpResourcesTitle: 'MCP Resources', noMCPServersSelected: 'No MCP servers selected', diff --git a/web/src/i18n/locales/es-ES.ts b/web/src/i18n/locales/es-ES.ts index 7f10d6055..72a0ce3ef 100644 --- a/web/src/i18n/locales/es-ES.ts +++ b/web/src/i18n/locales/es-ES.ts @@ -1635,6 +1635,17 @@ const esES = { restoringRunnerInstall: 'Restaurando la instalación del plugin y esperando al ejecutor…', noInstalledRunners: 'Aún no hay extensiones de ejecutor instaladas.', + runnerDiagnosis: { + missing: + 'No se encuentra el Runner seleccionado o su complemento. Instale o restaure el complemento.', + disabled: + 'El complemento del Runner seleccionado está deshabilitado. Actívelo en Complementos.', + runtime: + 'El entorno de ejecución de complementos no está disponible o está deshabilitado. Restablezca la conexión antes de ejecutar.', + repair: 'Gestionar complementos y entorno de ejecución', + draft: + 'La reparación se abre en otra pestaña. La selección del Runner y el borrador sin guardar se conservan aquí.', + }, installingRunner: 'Instalando {{runner}}...', runnerInstallSuccess: '{{runner}} está instalado y listo para seleccionarse', @@ -2253,6 +2264,10 @@ const esES = { addPlugin: 'Añadir plugin', selectPlugins: 'Seleccionar plugins', pluginsTitle: 'Plugins', + pluginScopeHint: + 'Solo controla los escuchadores de eventos, herramientas y comandos de este pipeline; no afecta al Runner elegido en los ajustes de IA.', + runnerScopeHint: + 'El Runner se elige aquí independientemente de los interruptores de plugins de extensiones. Su plugin debe estar instalado y disponible.', mcpServersTitle: 'Servidores MCP', mcpResourcesTitle: 'Recursos MCP', noMCPServersSelected: 'No hay servidores MCP seleccionados', diff --git a/web/src/i18n/locales/ja-JP.ts b/web/src/i18n/locales/ja-JP.ts index ec7e6fb77..21896f520 100644 --- a/web/src/i18n/locales/ja-JP.ts +++ b/web/src/i18n/locales/ja-JP.ts @@ -1618,6 +1618,17 @@ const jaJP = { restoringRunnerInstall: 'Runner プラグインのインストールを復元し、ランナーを待機しています…', noInstalledRunners: 'Runner 拡張機能はまだインストールされていません。', + runnerDiagnosis: { + missing: + '選択した Runner またはプラグインが見つかりません。プラグインをインストールまたは復元してください。', + disabled: + '選択した Runner プラグインは無効です。プラグイン画面で有効にしてください。', + runtime: + 'プラグインランタイムが利用できないか無効です。接続を復旧してから実行してください。', + repair: 'プラグインとランタイムを管理', + draft: + '修復画面は新しいタブで開きます。Runner の選択と未保存の下書きは保持されます。', + }, installingRunner: '{{runner}} をインストールしています...', runnerInstallSuccess: '{{runner}} をインストールしました。インストール済み一覧から選択できます', @@ -2228,6 +2239,10 @@ const jaJP = { addPlugin: 'プラグインを追加', selectPlugins: 'プラグインを選択', pluginsTitle: 'プラグイン', + pluginScopeHint: + 'このパイプラインのイベントリスナー、ツール、コマンドのみを制御します。AI 設定で選択した Runner には影響しません。', + runnerScopeHint: + 'Runner は拡張機能のプラグインスイッチと独立して選択します。対応するプラグインはインストール済みで利用可能である必要があります。', mcpServersTitle: 'MCPサーバー', mcpResourcesTitle: 'MCPリソース', noMCPServersSelected: 'MCPサーバーが選択されていません', diff --git a/web/src/i18n/locales/ru-RU.ts b/web/src/i18n/locales/ru-RU.ts index f987f12b1..8a92e4d53 100644 --- a/web/src/i18n/locales/ru-RU.ts +++ b/web/src/i18n/locales/ru-RU.ts @@ -1632,6 +1632,17 @@ const ruRU = { restoringRunnerInstall: 'Восстановление установки плагина и ожидание исполнителя…', noInstalledRunners: 'Расширения исполнителей ещё не установлены.', + runnerDiagnosis: { + missing: + 'Выбранный Runner или его плагин не найден. Установите или восстановите плагин.', + disabled: + 'Плагин выбранного Runner отключён. Включите его на странице плагинов.', + runtime: + 'Среда выполнения плагинов недоступна или отключена. Восстановите соединение перед запуском.', + repair: 'Управление плагинами и средой выполнения', + draft: + 'Исправление откроется в новой вкладке. Выбор Runner и несохранённый черновик останутся здесь.', + }, installingRunner: 'Установка {{runner}}...', runnerInstallSuccess: '{{runner}} установлен и доступен для выбора', selectedRunnerUnavailable: 'Выбранный исполнитель недоступен', @@ -2243,6 +2254,10 @@ const ruRU = { addPlugin: 'Добавить плагин', selectPlugins: 'Выберите плагины', pluginsTitle: 'Плагины', + pluginScopeHint: + 'Управляет только обработчиками событий, инструментами и командами этого конвейера, но не Runner, выбранным в настройках ИИ.', + runnerScopeHint: + 'Runner выбирается независимо от переключателей плагинов расширений. Его плагин должен быть установлен и доступен.', mcpServersTitle: 'MCP-серверы', mcpResourcesTitle: 'MCP-ресурсы', noMCPServersSelected: 'MCP-серверы не выбраны', diff --git a/web/src/i18n/locales/th-TH.ts b/web/src/i18n/locales/th-TH.ts index 5f11856bc..a53e5ff42 100644 --- a/web/src/i18n/locales/th-TH.ts +++ b/web/src/i18n/locales/th-TH.ts @@ -1577,6 +1577,17 @@ const thTH = { viewMarketplace: 'ดูตลาด', restoringRunnerInstall: 'กำลังกู้คืนการติดตั้งปลั๊กอินและรอรันเนอร์…', noInstalledRunners: 'ยังไม่ได้ติดตั้งส่วนขยายรันเนอร์', + runnerDiagnosis: { + missing: + 'ไม่พบ Runner ที่เลือกหรือปลั๊กอิน โปรดติดตั้งหรือกู้คืนปลั๊กอิน', + disabled: + 'ปลั๊กอิน Runner ที่เลือกถูกปิดใช้งาน โปรดเปิดใช้งานในหน้าปลั๊กอิน', + runtime: + 'รันไทม์ปลั๊กอินไม่พร้อมใช้งานหรือถูกปิด โปรดกู้คืนการเชื่อมต่อก่อนเรียกใช้', + repair: 'จัดการปลั๊กอินและรันไทม์', + draft: + 'หน้าซ่อมแซมจะเปิดในแท็บใหม่ การเลือก Runner และฉบับร่างที่ยังไม่บันทึกจะยังอยู่ที่นี่', + }, installingRunner: 'กำลังติดตั้ง {{runner}}...', runnerInstallSuccess: 'ติดตั้ง {{runner}} แล้ว พร้อมให้เลือก', selectedRunnerUnavailable: 'รันเนอร์ที่เลือกไม่พร้อมใช้งาน', @@ -2172,6 +2183,10 @@ const thTH = { addPlugin: 'เพิ่มปลั๊กอิน', selectPlugins: 'เลือกปลั๊กอิน', pluginsTitle: 'ปลั๊กอิน', + pluginScopeHint: + 'ควบคุมเฉพาะตัวรับฟังเหตุการณ์ เครื่องมือ และคำสั่งของไปป์ไลน์นี้ ไม่ส่งผลต่อ Runner ที่เลือกในการตั้งค่า AI', + runnerScopeHint: + 'เลือก Runner ได้อย่างอิสระจากสวิตช์ปลั๊กอินส่วนขยาย โดยปลั๊กอินของ Runner ต้องติดตั้งแล้วและพร้อมใช้งาน', mcpServersTitle: 'เซิร์ฟเวอร์ MCP', mcpResourcesTitle: 'ทรัพยากร MCP', noMCPServersSelected: 'ไม่ได้เลือกเซิร์ฟเวอร์ MCP', diff --git a/web/src/i18n/locales/vi-VN.ts b/web/src/i18n/locales/vi-VN.ts index 636ea68e6..a391096bf 100644 --- a/web/src/i18n/locales/vi-VN.ts +++ b/web/src/i18n/locales/vi-VN.ts @@ -1601,6 +1601,17 @@ const viVN = { viewMarketplace: 'Xem chợ tiện ích', restoringRunnerInstall: 'Đang khôi phục cài đặt plugin và chờ trình chạy…', noInstalledRunners: 'Chưa cài tiện ích trình chạy nào.', + runnerDiagnosis: { + missing: + 'Không tìm thấy Runner đã chọn hoặc tiện ích của nó. Hãy cài đặt hoặc khôi phục tiện ích.', + disabled: + 'Tiện ích Runner đã chọn bị tắt. Hãy bật nó trong trang Tiện ích.', + runtime: + 'Môi trường chạy tiện ích không khả dụng hoặc bị tắt. Hãy khôi phục kết nối trước khi chạy.', + repair: 'Quản lý tiện ích và môi trường chạy', + draft: + 'Trang sửa lỗi mở trong thẻ mới. Lựa chọn Runner và bản nháp chưa lưu vẫn được giữ ở đây.', + }, installingRunner: 'Đang cài {{runner}}...', runnerInstallSuccess: 'Đã cài {{runner}} và có thể chọn', selectedRunnerUnavailable: 'Trình chạy đã chọn không khả dụng', @@ -2205,6 +2216,10 @@ const viVN = { addPlugin: 'Thêm Plugin', selectPlugins: 'Chọn Plugin', pluginsTitle: 'Plugin', + pluginScopeHint: + 'Chỉ kiểm soát trình lắng nghe sự kiện, công cụ và lệnh của pipeline này; không ảnh hưởng đến Runner được chọn trong cài đặt AI.', + runnerScopeHint: + 'Chọn Runner độc lập tại đây, không phụ thuộc vào công tắc plugin tiện ích. Plugin của Runner vẫn phải được cài đặt và khả dụng.', mcpServersTitle: 'Máy chủ MCP', mcpResourcesTitle: 'Tài nguyên MCP', noMCPServersSelected: 'Chưa chọn máy chủ MCP nào', diff --git a/web/src/i18n/locales/zh-Hans.ts b/web/src/i18n/locales/zh-Hans.ts index 6b1e82046..009aef63d 100644 --- a/web/src/i18n/locales/zh-Hans.ts +++ b/web/src/i18n/locales/zh-Hans.ts @@ -1516,6 +1516,13 @@ const zhHans = { viewMarketplace: '查看市场', restoringRunnerInstall: '正在恢复运行器插件安装并等待运行器就绪…', noInstalledRunners: '尚未安装任何运行器扩展。', + runnerDiagnosis: { + missing: '所选运行器或其插件不存在,请安装或恢复插件。', + disabled: '所选运行器插件已禁用,请在插件页面启用。', + runtime: '插件运行时不可用或已禁用,请恢复连接后再运行。', + repair: '管理插件和运行时', + draft: '修复页面将在新标签页打开,当前运行器选择和未保存的草稿将保留。', + }, installingRunner: '正在安装 {{runner}}...', runnerInstallSuccess: '{{runner}} 已安装,可从已安装列表中选择', selectedRunnerUnavailable: '所选运行器不可用', @@ -2092,6 +2099,10 @@ const zhHans = { addPlugin: '添加插件', selectPlugins: '选择插件', pluginsTitle: '插件', + pluginScopeHint: + '此处仅控制本流水线使用的事件监听器、工具和命令组件,不影响 AI 设置中选择的运行器(Runner)。', + runnerScopeHint: + '运行器在此独立选择,不受扩展集成中的插件开关控制;其所属插件仍需已安装且可用。', mcpServersTitle: 'MCP 服务器', mcpResourcesTitle: 'MCP 资源', noMCPServersSelected: '未选择任何 MCP 服务器', diff --git a/web/src/i18n/locales/zh-Hant.ts b/web/src/i18n/locales/zh-Hant.ts index 8a8b749c2..9ca1c8b97 100644 --- a/web/src/i18n/locales/zh-Hant.ts +++ b/web/src/i18n/locales/zh-Hant.ts @@ -1514,6 +1514,13 @@ const zhHant = { viewMarketplace: '查看市場', restoringRunnerInstall: '正在恢復運行器插件安裝並等待運行器就緒…', noInstalledRunners: '尚未安裝任何運行器擴展。', + runnerDiagnosis: { + missing: '所選運行器或其外掛不存在,請安裝或還原外掛。', + disabled: '所選運行器外掛已停用,請在外掛頁面啟用。', + runtime: '外掛執行環境無法使用或已停用,請恢復連線後再執行。', + repair: '管理外掛和執行環境', + draft: '修復頁面將在新分頁開啟,目前的運行器選擇和未儲存的草稿將保留。', + }, installingRunner: '正在安裝 {{runner}}...', runnerInstallSuccess: '{{runner}} 已安裝,可從已安裝列表中選擇', selectedRunnerUnavailable: '所選運行器不可用', @@ -2089,6 +2096,10 @@ const zhHant = { addPlugin: '新增插件', selectPlugins: '選擇插件', pluginsTitle: '插件', + pluginScopeHint: + '此處僅控制本流水線使用的事件監聽器、工具和命令元件,不影響 AI 設定中選擇的執行器(Runner)。', + runnerScopeHint: + '執行器在此獨立選擇,不受擴充整合中的外掛開關控制;其所屬外掛仍須已安裝且可用。', mcpServersTitle: 'MCP 伺服器', mcpResourcesTitle: 'MCP 資源', noMCPServersSelected: '未選擇任何 MCP 伺服器', diff --git a/web/tests/e2e/runner-extension-boundary.spec.ts b/web/tests/e2e/runner-extension-boundary.spec.ts new file mode 100644 index 000000000..d4220ea3b --- /dev/null +++ b/web/tests/e2e/runner-extension-boundary.spec.ts @@ -0,0 +1,167 @@ +import { expect, test } from '@playwright/test'; +import { + installLangBotApiMocks, + pipelineMetadata, +} from './fixtures/langbot-api'; + +test('independent Runner guidance remains visible with extensions disabled', async ({ + page, +}) => { + await installLangBotApiMocks(page, { authenticated: true }); + await page.route('**/api/v1/pipelines/*/extensions', (route) => + route.fulfill({ + json: { + code: 0, + data: { + enable_all_plugins: false, + enable_all_mcp_servers: false, + enable_all_skills: false, + bound_plugins: [], + available_plugins: [], + bound_mcp_servers: [], + available_mcp_servers: [], + bound_skills: [], + available_skills: [], + }, + }, + }), + ); + await page.goto('/home/pipelines?id=boundary'); + await page.locator('[data-guide="pipeline-section-ai"]').click(); + await expect( + page.getByText( + 'Select the Runner independently here, regardless of extension plugin switches. Its plugin must still be installed and available.', + ), + ).toBeVisible(); + await page.getByRole('button', { name: 'Extensions', exact: true }).click(); + await expect( + page.getByText( + 'Only controls event listeners, tools and commands for this pipeline; does not affect the Runner selected in AI settings.', + ), + ).toBeVisible(); +}); + +for (const problem of ['missing', 'disabled', 'runtime'] as const) { + test(`Runner ${problem} diagnosis keeps the draft and opens repair separately`, async ({ + page, + }) => { + await installLangBotApiMocks(page, { authenticated: true }); + const id = 'plugin:langbot-team/LocalAgent/default'; + const config = { + ai: { runner: { id }, runner_config: { [id]: { hidden: null } } }, + }; + const writes: unknown[] = []; + await page.route('**/api/v1/pipelines/diagnosis', async (route) => { + if (route.request().method() === 'PUT') + writes.push(route.request().postDataJSON()); + await route.fulfill({ + json: { + code: 0, + data: { + pipeline: { + uuid: 'diagnosis', + name: 'Diagnosis', + description: '', + emoji: '⚙️', + config, + }, + }, + }, + }); + }); + await page.route('**/api/v1/system/status/plugin-system', (route) => + route.fulfill({ + json: { + code: 0, + data: { + is_enable: true, + is_connected: problem !== 'runtime', + plugin_connector_error: '', + }, + }, + }), + ); + await page.route('**/api/v1/plugins', (route) => + route.fulfill({ + json: { + code: 0, + data: { + plugins: + problem === 'missing' + ? [] + : [ + { + enabled: problem !== 'disabled', + manifest: { + manifest: { + metadata: { + author: 'langbot-team', + name: 'LocalAgent', + }, + }, + }, + }, + ], + }, + }, + }), + ); + const originalMetadata = pipelineMetadata(); + const metadata = { + configs: originalMetadata.configs.map((tab) => ({ + ...tab, + stages: tab.stages.map((stage) => ({ + ...stage, + config: + stage.name === id + ? [ + { + name: 'draft', + type: 'string', + label: { en_US: 'Draft marker', zh_Hans: '草稿标记' }, + required: false, + default: '', + }, + ] + : stage.config.map((field) => + problem === 'missing' && field.name === 'id' + ? { ...field, options: [] } + : field, + ), + })), + })), + }; + await page.route('**/api/v1/pipelines/_/metadata', (route) => + route.fulfill({ json: { code: 0, data: metadata } }), + ); + await page.goto('/home/pipelines?id=diagnosis'); + await page.locator('[data-guide="pipeline-section-ai"]').click(); + const draft = page + .getByRole('region', { name: 'Configuration', exact: true }) + .getByRole('textbox'); + await draft.fill('unsaved diagnosis draft'); + const alert = page + .getByRole('alert') + .filter({ hasText: 'Manage plugins and runtime' }); + await expect(alert).toContainText( + { + missing: 'The selected Runner or its plugin is missing.', + disabled: 'The selected Runner plugin is disabled.', + runtime: 'The Plugin Runtime is unavailable or disabled.', + }[problem], + ); + const repair = alert.getByRole('link', { + name: 'Manage plugins and runtime', + }); + await expect(repair).toHaveAttribute('target', '_blank'); + const popup = page.waitForEvent('popup'); + await repair.click(); + await (await popup).close(); + await expect(page).toHaveURL(/id=diagnosis/); + await expect(draft).toHaveValue('unsaved diagnosis draft'); + await expect( + page.getByRole('button', { name: 'Save', exact: true }), + ).toBeEnabled(); + expect(writes).toEqual([]); + }); +} diff --git a/web/tests/e2e/runner-real-runtime.spec.ts b/web/tests/e2e/runner-real-runtime.spec.ts new file mode 100644 index 000000000..1b19cfaa1 --- /dev/null +++ b/web/tests/e2e/runner-real-runtime.spec.ts @@ -0,0 +1,101 @@ +import { expect, test } from '@playwright/test'; + +test.skip( + !process.env.LANGBOT_RUNNER_PIPELINE_ID, + 'Requires the isolated real backend and SDK pytest fixture', +); + +test('real backend and SDK: save, copy and execute with extensions off', async ({ + page, + request, +}) => { + const api = process.env.LANGBOT_RUNNER_API_URL!; + const pipelineId = process.env.LANGBOT_RUNNER_PIPELINE_ID!; + const token = process.env.LANGBOT_RUNNER_TOKEN!; + const runnerId = 'plugin:e2e/agent-runner-qa/default'; + const headers = { Authorization: `Bearer ${token}` }; + await page.addInitScript( + ({ token }) => { + localStorage.setItem('token', token); + localStorage.setItem('userEmail', 'admin@langbot.test'); + localStorage.setItem('langbot_language', 'en-US'); + for (const key of [ + 'langbot_sidebar_guide_v1', + 'langbot_pipeline_setup_guide_v1', + ]) { + localStorage.setItem(key, 'completed'); + } + }, + { token }, + ); + await page.goto(`/home/pipelines?id=${pipelineId}`, { + waitUntil: 'domcontentloaded', + }); + await page.getByRole('button', { name: 'Skip', exact: true }).click(); + await page + .getByRole('dialog') + .getByRole('button', { name: 'OK', exact: true }) + .click(); + await expect( + page.getByRole('dialog', { name: 'Skip', exact: true }), + ).not.toBeVisible(); + await page.goto(`/home/pipelines?id=${pipelineId}`, { + waitUntil: 'domcontentloaded', + }); + await page.locator('[data-guide="pipeline-section-ai"]').click(); + await expect( + page.getByRole('combobox', { name: 'Runner', exact: true }), + ).toContainText('QA Echo Runner'); + const prefixInput = page + .getByRole('region', { name: 'Configuration', exact: true }) + .getByRole('textbox'); + await prefixInput.fill('browser saved'); + const savedResponse = page.waitForResponse( + (response) => + response.url().endsWith(`/api/v1/pipelines/${pipelineId}`) && + response.request().method() === 'PUT', + ); + await page.getByRole('button', { name: 'Save', exact: true }).click(); + expect((await (await savedResponse).json()).code).toBe(0); + await page.reload({ waitUntil: 'domcontentloaded' }); + await page.locator('[data-guide="pipeline-section-ai"]').click(); + await expect(prefixInput).toHaveValue('browser saved'); + await page.getByRole('button', { name: 'Management', exact: true }).click(); + await page.getByRole('button', { name: 'Copy', exact: true }).click(); + const copiedResponse = page.waitForResponse( + (response) => + response.url().endsWith(`/api/v1/pipelines/${pipelineId}/copy`) && + response.request().method() === 'POST', + ); + await page + .getByRole('dialog') + .getByRole('button', { name: 'Confirm', exact: true }) + .click(); + const copiedPayload = await (await copiedResponse).json(); + expect(copiedPayload.code).toBe(0); + const copiedId = copiedPayload.data.uuid; + for (const id of [pipelineId, copiedId]) { + const response = await request.get(`${api}/api/v1/pipelines/${id}`, { + headers, + }); + expect(response.ok()).toBeTruthy(); + const stored = (await response.json()).data.pipeline; + expect(stored.config.ai.runner.id).toBe(runnerId); + expect(stored.config.ai.runner_config[runnerId]).toEqual({ + prefix: 'browser saved', + hidden: { keep: [null, false, ' raw '] }, + }); + expect(stored.extensions_preferences.enable_all_plugins).toBe(false); + expect(stored.extensions_preferences.plugins).toEqual([]); + await page.goto(`/home/pipelines?id=${id}`, { + waitUntil: 'domcontentloaded', + }); + const input = page.getByPlaceholder('Send Private Chat message...'); + await expect(input).toBeEnabled(); + await input.fill(`Chromium ${id}`); + await input.press('Enter'); + await expect( + page.getByText(`browser saved: Chromium ${id}`, { exact: true }), + ).toBeVisible({ timeout: 20_000 }); + } +}); diff --git a/web/tests/unit/runner-diagnosis-locales.test.mjs b/web/tests/unit/runner-diagnosis-locales.test.mjs new file mode 100644 index 000000000..783339e2b --- /dev/null +++ b/web/tests/unit/runner-diagnosis-locales.test.mjs @@ -0,0 +1,61 @@ +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import test from 'node:test'; +import ts from 'typescript'; + +test('Runner diagnosis and draft-safe repair have all eight complete catalogs', () => { + const locales = [ + 'en-US', + 'zh-Hans', + 'zh-Hant', + 'ja-JP', + 'es-ES', + 'ru-RU', + 'th-TH', + 'vi-VN', + ]; + const expected = ['disabled', 'draft', 'missing', 'repair', 'runtime']; + for (const locale of locales) { + const source = fs.readFileSync( + new URL(`../../src/i18n/locales/${locale}.ts`, import.meta.url), + 'utf8', + ); + const ast = ts.createSourceFile( + `${locale}.ts`, + source, + ts.ScriptTarget.Latest, + true, + ); + const found = []; + function visit(node) { + if ( + ts.isPropertyAssignment(node) && + node.name.getText(ast) === 'runnerDiagnosis' + ) { + assert.ok(ts.isObjectLiteralExpression(node.initializer), locale); + const fields = node.initializer.properties; + assert.deepEqual( + fields.map((field) => field.name.getText(ast)).sort(), + expected, + locale, + ); + for (const field of fields) { + assert.ok( + ts.isStringLiteral(field.initializer) && + field.initializer.text.trim(), + locale, + ); + assert.equal( + field.initializer.text.match(/{{[^}]+}}/g), + null, + locale, + ); + } + found.push(node); + } + ts.forEachChild(node, visit); + } + visit(ast); + assert.equal(found.length, 1, locale); + } +});