Files
LangBot/tests/e2e/test_runner_plugin_runtime.py
T
RockChinQandmultica-agent d9e4e7d9df 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]>
2026-10-19 23:07:11 +08:00

826 lines
31 KiB
Python

"""E2E tests for pluginized Runner execution.
This module starts the real LangBot backend with the plugin system enabled and
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
import sys
import tempfile
import textwrap
import time
from pathlib import Path
import httpx
import pytest
from tests.e2e.utils.config_factory import create_minimal_config, create_test_directories
from tests.e2e.utils.process_manager import LangBotProcess, find_project_root
pytestmark = pytest.mark.e2e
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 _free_port()
@pytest.fixture(scope='session')
def runner_e2e_tmpdir():
"""Create temporary directory for Runner E2E testing."""
root = find_project_root() / '.qa' / 'lang54'
root.mkdir(parents=True, exist_ok=True)
tmpdir = Path(tempfile.mkdtemp(prefix='runner-runtime-', dir=root))
yield tmpdir
if not os.environ.get('LANGBOT_KEEP_RUNNER_E2E'):
shutil.rmtree(tmpdir, ignore_errors=True)
def _write_qa_runner_plugin(plugin_root: Path) -> None:
"""Write a deterministic Runner plugin used by this E2E."""
runner_dir = plugin_root / 'components' / 'runner'
runner_dir.mkdir(parents=True, exist_ok=True)
(plugin_root / 'assets').mkdir(parents=True, exist_ok=True)
(plugin_root / 'assets' / 'icon.svg').write_text(
'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1 1"></svg>',
encoding='utf-8',
)
(plugin_root / 'manifest.yaml').write_text(
textwrap.dedent(
"""
apiVersion: langbot/v1
kind: Plugin
metadata:
author: e2e
name: agent-runner-qa
version: 0.1.0
label:
en_US: Runner QA
zh_Hans: Runner QA
description:
en_US: Deterministic Runner E2E probe.
zh_Hans: 确定性的 Runner E2E 探针。
icon: assets/icon.svg
spec:
version: 0.1.0
config: []
components:
Runner:
fromDirs:
- path: components/runner/
Tool:
fromDirs:
- path: components/tools/
Command:
fromDirs:
- path: components/commands/
EventListener:
fromDirs:
- path: components/event_listener/
pages: []
execution:
python:
path: main.py
attr: RunnerQAPlugin
"""
).strip()
+ '\n',
encoding='utf-8',
)
(plugin_root / 'main.py').write_text(
textwrap.dedent(
"""
from __future__ import annotations
from langbot_plugin.api.definition.plugin import BasePlugin
class RunnerQAPlugin(BasePlugin):
async def initialize(self) -> None:
pass
"""
).strip()
+ '\n',
encoding='utf-8',
)
(runner_dir / 'default.yaml').write_text(
textwrap.dedent(
"""
apiVersion: langbot/v1
kind: Runner
metadata:
name: default
label:
en_US: QA Echo Runner
zh_Hans: QA Echo Runner
description:
en_US: Echoes input and exercises run-scoped state APIs.
zh_Hans: 回显输入并验证运行级状态 API。
spec:
usages: [agent]
config:
- name: prefix
type: string
label:
en_US: Echo prefix
zh_Hans: 回显前缀
default: 'e2e echo'
required: false
capabilities:
streaming: false
tool_calling: true
permissions:
tools: [detail, call]
execution:
python:
path: default.py
attr: DefaultRunner
"""
).strip()
+ '\n',
encoding='utf-8',
)
(runner_dir / 'default.py').write_text(
textwrap.dedent(
"""
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
from langbot_plugin.api.entities.builtin.runner.result import RunnerResult
from langbot_plugin.api.entities.builtin.provider.message import Message
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=content),
)
yield RunnerResult.state_updated(
ctx.run_id,
'e2e.echo_count',
{'count': 1},
scope='conversation',
)
yield RunnerResult.run_completed(ctx.run_id, finish_reason='stop')
"""
).strip()
+ '\n',
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("""
apiVersion: langbot/v1
kind: Runner
metadata:
name: welcome
label: {en_US: Welcome processor, zh_Hans: Welcome processor}
spec:
usages: [event]
events: [group.member_joined]
config:
- name: greeting
type: string
required: true
label: {en_US: Greeting, zh_Hans: Greeting}
default: Hello
capabilities: {tool_calling: true}
permissions:
tools: [detail, call]
execution:
python: {path: welcome.py, attr: WelcomeProcessor}
""")
)
(processor_dir / 'welcome.py').write_text(
textwrap.dedent("""
from langbot_plugin.api.definition.components.runner import Runner, RunnerContext
from langbot_plugin.api.entities.builtin.platform.events import MemberJoinedEvent
class WelcomeProcessor(Runner):
async def initialize(self):
@self.handler(MemberJoinedEvent)
async def handle(ctx: RunnerContext):
await ctx.log('Handling ' + str(ctx.platform_event.member.id))
tools = await ctx.get_available_tools()
assert any(tool['name'] == 'event_reply' for tool in tools)
result = await self.plugin.call_tool('event_reply', {'text': ctx.config['greeting'] + ', ' + (ctx.platform_event.member.nickname or str(ctx.platform_event.member.id))})
await ctx.log('Reply simulated: ' + str(result.get('mock')))
""")
)
def _free_port() -> int:
"""Reserve a currently-free localhost TCP port for this E2E process."""
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock:
sock.bind(('127.0.0.1', 0))
return int(sock.getsockname()[1])
@pytest.fixture(scope='session')
def runner_runtime_ports():
"""Control/debug ports for the standalone plugin runtime."""
control_port = _free_port()
debug_port = _free_port()
while debug_port == control_port:
debug_port = _free_port()
return control_port, debug_port
@pytest.fixture(scope='session')
def runner_e2e_config_path(runner_e2e_tmpdir, runner_e2e_port, runner_runtime_ports):
"""Create a plugin-enabled config and deterministic Runner fixture."""
config_path = create_minimal_config(runner_e2e_tmpdir, port=runner_e2e_port)
create_test_directories(runner_e2e_tmpdir)
import yaml
with open(config_path, encoding='utf-8') as f:
config = yaml.safe_load(f)
config['api']['global_api_key'] = 'e2e-agent-runner-key'
runtime_control_port, _runtime_debug_port = runner_runtime_ports
config['plugin']['enable'] = True
config['plugin']['runtime_ws_url'] = f'ws://127.0.0.1:{runtime_control_port}/control/ws'
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)
plugin_source = runner_e2e_tmpdir / 'agent-runner-qa-package'
_write_qa_runner_plugin(plugin_source)
shutil.make_archive(
str(runner_e2e_tmpdir / 'agent-runner-qa'),
'zip',
root_dir=plugin_source,
)
return config_path
@pytest.fixture(scope='session')
def runner_runtime_process(runner_e2e_tmpdir, runner_runtime_ports):
"""Start the real SDK plugin runtime over WebSocket."""
control_port, debug_port = runner_runtime_ports
stdout_path = runner_e2e_tmpdir / 'plugin-runtime.stdout.log'
stderr_path = runner_e2e_tmpdir / 'plugin-runtime.stderr.log'
stdout_file = open(stdout_path, 'wb')
stderr_file = open(stderr_path, 'wb')
proc = subprocess.Popen(
[
sys.executable,
'-m',
'langbot_plugin.cli.__init__',
'rt',
'--ws-control-port',
str(control_port),
'--ws-debug-port',
str(debug_port),
],
cwd=runner_e2e_tmpdir,
stdout=stdout_file,
stderr=stderr_file,
start_new_session=True,
)
yield proc
try:
os.killpg(proc.pid, signal.SIGTERM)
proc.wait(timeout=5)
except subprocess.TimeoutExpired:
os.killpg(proc.pid, signal.SIGKILL)
proc.wait()
except ProcessLookupError:
pass
stdout_file.close()
stderr_file.close()
@pytest.fixture(scope='session')
def runner_langbot_process(
runner_e2e_config_path,
runner_e2e_port,
runner_e2e_tmpdir,
runner_runtime_process,
):
"""Start real LangBot with plugin runtime enabled."""
project_root = find_project_root()
proc = LangBotProcess(
project_root=project_root,
work_dir=runner_e2e_tmpdir,
port=runner_e2e_port,
timeout=360,
debug=False,
collect_coverage=False,
cli_args=['--standalone-runtime'],
)
success = proc.start()
if not success:
stdout, stderr = proc.get_logs()
pytest.fail(f'LangBot failed to start with Runner plugin runtime:\nstdout: {stdout}\nstderr: {stderr}')
yield proc
proc.stop()
@pytest.fixture
def runner_client(runner_e2e_port, runner_langbot_process):
"""HTTP client for the Runner E2E backend."""
with httpx.Client(
base_url=f'http://127.0.0.1:{runner_e2e_port}',
timeout=90.0,
trust_env=False,
) as client:
yield client
def _init_and_auth(client: httpx.Client) -> str:
"""Initialize the test admin user and return a bearer token."""
credentials = {'user': '[email protected]', 'password': 'admin'}
init_resp = client.post('/api/v1/user/init', json=credentials)
assert init_resp.status_code == 200
assert init_resp.json()['code'] in [0, 1]
auth_resp = client.post('/api/v1/user/auth', json=credentials)
assert auth_resp.status_code == 200
payload = auth_resp.json()
assert payload['code'] == 0
return payload['data']['token']
def _install_qa_plugin(client: httpx.Client, token: str, package_path: Path) -> None:
"""Install the QA Runner through the same asynchronous local-upload API as the UI."""
headers = {'Authorization': f'Bearer {token}'}
with package_path.open('rb') as package_file:
response = client.post(
'/api/v1/plugins/install/local',
headers=headers,
files={'file': ('agent-runner-qa.zip', package_file, 'application/zip')},
)
assert response.status_code == 200, response.text
payload = response.json()
assert payload['code'] == 0, payload
task_id = payload['data']['task_id']
deadline = time.time() + 90
while time.time() < deadline:
task_response = client.get(f'/api/v1/system/tasks/{task_id}', headers=headers)
assert task_response.status_code == 200, task_response.text
task_payload = task_response.json()
assert task_payload['code'] == 0, task_payload
task = task_payload['data']
if task['runtime']['done']:
assert task['runtime']['exception'] is None, task
assert task['task_context']['metadata']['progress_percent'] == 100
return
time.sleep(1)
raise AssertionError(f'Plugin installation task {task_id} did not complete')
def _wait_for_qa_runner(client: httpx.Client, token: str, timeout: float = 60) -> set[str]:
"""Return the latest Runner option set, waiting for the QA Runner when needed."""
deadline = time.time() + timeout
option_names: set[str] = set()
while time.time() < deadline:
response = client.get(
'/api/v1/pipelines/_/metadata',
headers={'Authorization': f'Bearer {token}'},
)
assert response.status_code == 200, response.text
data = response.json()
assert data['code'] == 0, data
metadata_groups = data['data']['configs']
ai_metadata = next(group for group in metadata_groups if group.get('name') == 'ai')
runner_stage = next(stage for stage in ai_metadata['stages'] if stage['name'] == 'runner')
runner_select = next(item for item in runner_stage['config'] if item['name'] == 'id')
option_names = {option['name'] for option in runner_select['options']}
if QA_RUNNER_ID in option_names:
break
time.sleep(1)
return option_names
def _ensure_qa_plugin(client: httpx.Client, token: str, package_path: Path) -> None:
if QA_RUNNER_ID in _wait_for_qa_runner(client, token, timeout=2):
return
_install_qa_plugin(client, token, package_path)
def test_plugin_runtime_discovers_runner(
runner_client,
runner_langbot_process,
runner_e2e_tmpdir,
):
"""Pipeline metadata should include the real runtime-discovered QA runner."""
token = _init_and_auth(runner_client)
_ensure_qa_plugin(
runner_client,
token,
runner_e2e_tmpdir / 'agent-runner-qa.zip',
)
option_names = _wait_for_qa_runner(runner_client, token)
if QA_RUNNER_ID in option_names:
return
host_stdout, host_stderr = runner_langbot_process.get_logs()
runtime_stdout = (runner_e2e_tmpdir / 'plugin-runtime.stdout.log').read_text(encoding='utf-8', errors='replace')
runtime_stderr = (runner_e2e_tmpdir / 'plugin-runtime.stderr.log').read_text(encoding='utf-8', errors='replace')
assert QA_RUNNER_ID in option_names, (
f'{QA_RUNNER_ID} was not discovered\n'
f'Host stdout (tail):\n{host_stdout[-20_000:]}\nHost stderr (tail):\n{host_stderr[-20_000:]}\n'
f'Runtime stdout (tail):\n{runtime_stdout[-20_000:]}\n'
f'Runtime stderr (tail):\n{runtime_stderr[-20_000:]}'
)
def test_host_orchestrator_runs_runner_and_records_ledger(
runner_client,
runner_langbot_process,
runner_e2e_tmpdir,
):
"""Create/configure/debug an Agent through HTTP and persist Runner side effects."""
del runner_langbot_process
token = _init_and_auth(runner_client)
_ensure_qa_plugin(
runner_client,
token,
runner_e2e_tmpdir / 'agent-runner-qa.zip',
)
headers = {'Authorization': f'Bearer {token}'}
create_response = runner_client.post(
'/api/v1/agents',
headers=headers,
json={
'kind': 'agent',
'name': 'Runner E2E Agent',
'description': 'Exercises the installed QA Runner.',
'emoji': 'QA',
'supported_event_patterns': ['message.*'],
'config': {
'runner': {'id': QA_RUNNER_ID},
'runner_config': {QA_RUNNER_ID: {}},
'allowed_platform_tools': ['event_reply', 'platform_get_user_info'],
},
},
)
assert create_response.status_code == 200, create_response.text
create_payload = create_response.json()
assert create_payload['code'] == 0, create_payload
agent_uuid = create_payload['data']['uuid']
get_response = runner_client.get(f'/api/v1/agents/{agent_uuid}', headers=headers)
assert get_response.status_code == 200, get_response.text
stored_agent = get_response.json()['data']['agent']
assert stored_agent['config']['allowed_platform_tools'] == [
'event_reply',
'platform_get_user_info',
]
debug_response = runner_client.post(
f'/api/v1/agents/{agent_uuid}/debug',
headers=headers,
json={
'event_type': 'message.received',
'text': 'hello from orchestrator e2e',
'conversation_id': 'e2e-conversation',
},
)
assert debug_response.status_code == 200, debug_response.text
debug_payload = debug_response.json()
assert debug_payload['code'] == 0, debug_payload
result = debug_payload['data']
assert result['final_text'] == 'e2e echo: hello from orchestrator e2e'
assert result['outputs'][0]['role'] == 'assistant'
db_path = runner_e2e_tmpdir / 'data' / 'langbot.db'
conn = sqlite3.connect(str(db_path))
try:
run_row = conn.execute(
'SELECT status, runner_id FROM agent_run WHERE event_id = ?',
(result['event_id'],),
).fetchone()
assert run_row == ('completed', QA_RUNNER_ID)
event_types = {
row[0]
for row in conn.execute(
'SELECT type FROM agent_run_event WHERE run_id = (SELECT run_id FROM agent_run WHERE event_id = ?)',
(result['event_id'],),
).fetchall()
}
assert {'state.updated', 'message.completed', 'run.completed'}.issubset(event_types)
state_row = conn.execute("SELECT value_json FROM runner_state WHERE state_key = 'e2e.echo_count'").fetchone()
assert state_row is not None
assert '"count": 1' in state_row[0]
finally:
conn.close()
def test_event_processor_real_runtime_logs_actions_and_instance_isolation(
runner_client,
runner_e2e_tmpdir,
):
client = runner_client
token = _init_and_auth(client)
_ensure_qa_plugin(client, token, runner_e2e_tmpdir / 'agent-runner-qa.zip')
headers = {'Authorization': f'Bearer {token}'}
metadata_response = client.get('/api/v1/agents/_/metadata', headers=headers).json()
assert metadata_response['code'] == 0, metadata_response
metadata = metadata_response['data']
ref = 'plugin:e2e/agent-runner-qa/welcome'
assert any(item['id'] == ref for item in metadata['event_processors']), metadata
assert ref not in _wait_for_qa_runner(client, token)
created = []
for name in ('First', 'Second'):
response = client.post(
'/api/v1/agents',
headers=headers,
json={
'kind': 'event_processor',
'name': name,
'component_ref': ref,
'parameters': {'greeting': name},
},
).json()
assert response['code'] == 0, response
created.append(response['data']['uuid'])
for processor_id in created:
page = client.get(f'/api/v1/agents/{processor_id}/runs', headers=headers).json()
assert page['data']['items'] == [], page
result = client.post(
f'/api/v1/agents/{created[0]}/debug',
headers=headers,
json={
'event_type': 'group.member_joined',
'data': {'member': {'id': 'member-1', 'nickname': 'Tester'}, 'group': {'id': 'group-1'}},
},
).json()
assert result['code'] == 0, result
logs = [event['data']['text'] for event in result['data']['execution_events'] if event['type'] == 'processor.log']
assert logs == ['Handling member-1', 'Reply simulated: True'], result
actions = [event for event in result['data']['execution_events'] if event['type'] == 'tool.call.completed']
assert len(actions) == 1, result
assert actions[0]['data']['tool_name'] == 'event_reply'
assert actions[0]['data']['result']['mock'] is True
assert result['data']['final_text'] == '', result
page = client.get(f'/api/v1/agents/{created[0]}/runs', headers=headers).json()['data']
assert len(page['items']) == 1, page
run = page['items'][0]
assert run['status'] == 'completed', run
assert run['metadata']['input_event']['member']['id'] == 'member-1'
trace = client.get(f'/api/v1/agents/{created[0]}/runs/{run["run_id"]}/events', headers=headers).json()
assert trace['code'] == 0, trace
assert any(item['type'] == 'processor.log' for item in trace['data']['items']), trace
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)