fix(pipeline): decouple Runner from extension permissions (LANG-54) (#2723)

* fix(pipeline): decouple runner from extension allowlist

Co-authored-by: multica-agent <[email protected]>

* fix(pipeline): complete runner diagnostics and real runtime coverage

Co-authored-by: multica-agent <[email protected]>

---------

Co-authored-by: multica-agent <[email protected]>
This commit is contained in:
RockChinQ
2026-10-19 23:07:11 +08:00
committed by GitHub
co-authored by multica-agent
parent 5a6ddbff6f
commit d9e4e7d9df
24 changed files with 1012 additions and 35 deletions
+14
View File
@@ -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://<host>:5300/mcp` with the key header.
3. Call `get_system_info` to confirm connectivity.
+5 -8
View File
@@ -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
+2 -1
View File
@@ -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
+9 -2
View File
@@ -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:
+268 -10
View File
@@ -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)
+11 -10
View File
@@ -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:
@@ -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))
@@ -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')
@@ -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'}}}},
],
},
+30
View File
@@ -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 },
},
});
@@ -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<RunnerCatalog['installedPluginDescriptions']>({});
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 ? (
<span className="truncate">{value}</span>
) : (
<SelectValue
placeholder={t(
@@ -539,6 +580,24 @@ export default function RunnerSelect({
</a>
</SelectContent>
</Select>
{!diagnosing && runnerProblem && (
<div
role="alert"
className="space-y-1 text-xs text-amber-700 dark:text-amber-400"
>
<p>{t(`agents.runnerDiagnosis.${runnerProblem}`)}</p>
<a
href="/home/plugins"
target="_blank"
rel="noreferrer"
className="inline-flex items-center gap-1 underline"
>
{t('agents.runnerDiagnosis.repair')}
<ExternalLink className="size-3" />
</a>
<p>{t('agents.runnerDiagnosis.draft')}</p>
</div>
)}
{installError && (
<p role="alert" className="text-sm text-destructive">
@@ -353,6 +353,9 @@ export default function PipelineExtension({
<div className="space-y-6">
{/* Plugins Section */}
<div className="space-y-3">
<p className="text-sm text-muted-foreground">
{t('pipelines.extensions.pluginScopeHint')}
</p>
<div className="flex items-center justify-between">
<h3 className="text-sm font-semibold text-foreground">
{t('pipelines.extensions.pluginsTitle')}
@@ -723,6 +723,9 @@ const PipelineFormComponent = forwardRef<
<Card key={stage.name}>
<CardHeader>
<CardTitle>{extractI18nObject(stage.label)}</CardTitle>
<CardDescription>
{t('pipelines.extensions.runnerScopeHint')}
</CardDescription>
{stage.description && (
<CardDescription>
{extractI18nObject(stage.description)}
+14
View File
@@ -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',
+15
View File
@@ -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',
+15
View File
@@ -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サーバーが選択されていません',
+15
View File
@@ -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-серверы не выбраны',
+15
View File
@@ -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',
+15
View File
@@ -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',
+11
View File
@@ -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 服务器',
+11
View File
@@ -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 伺服器',
@@ -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([]);
});
}
+101
View File
@@ -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', '[email protected]');
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 });
}
});
@@ -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);
}
});