mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-10-23 00:45:25 +08:00
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:
co-authored by
multica-agent
parent
5a6ddbff6f
commit
d9e4e7d9df
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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'}}}},
|
||||
],
|
||||
},
|
||||
|
||||
@@ -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)}
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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サーバーが選択されていません',
|
||||
|
||||
@@ -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-серверы не выбраны',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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 服务器',
|
||||
|
||||
@@ -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([]);
|
||||
});
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user