feat(agent-runner): enforce 4.x host-owned execution

This commit is contained in:
huanghuoguoguo
2026-07-12 20:36:32 +08:00
parent e6384aae5d
commit 99d9c227f9
171 changed files with 6958 additions and 5385 deletions
+85 -7
View File
@@ -32,6 +32,7 @@ class TestHandlerQueryVariables:
app.logger = SimpleNamespace()
app.logger.debug = MagicMock()
app.logger.warning = MagicMock()
return app
@@ -71,6 +72,90 @@ class TestHandlerQueryVariables:
assert response.code == 0
assert mock_query.variables['test_var'] == 'test_value'
@pytest.mark.asyncio
@pytest.mark.parametrize(
'key',
[
'_host_box_scope',
'_host_tool_source_refs',
'_pipeline_bound_plugins',
'_pipeline_bound_mcp_servers',
'_pipeline_bound_skills',
'_pipeline_mcp_resource_attachments',
'_pipeline_mcp_resource_agent_read_enabled',
'_activated_skills',
'_fallback_model_uuids',
'_monitoring_message_id',
'_sandbox_outbound_collected',
'_authorized_models',
'_permission_tools',
'_routed_by_rule',
],
)
async def test_set_query_var_rejects_host_reserved_keys(self, mock_app, key):
runtime_handler = make_handler(mock_app)
original_variables = {key: 'host-owned'}
mock_query = SimpleNamespace(variables=original_variables.copy())
mock_app.query_pool.cached_queries['test-query'] = mock_query
response = await runtime_handler.actions[PluginToRuntimeAction.SET_QUERY_VAR.value](
{
'query_id': 'test-query',
'key': key,
'value': 'plugin-overwrite',
}
)
assert response.code != 0
assert response.message == f'Query variable {key!r} is reserved for LangBot Host'
assert mock_query.variables == original_variables
mock_app.logger.warning.assert_called_once()
@pytest.mark.asyncio
@pytest.mark.parametrize(
'key',
[
'business_context',
'_ltm_context',
'_knowledge_base_uuids',
'_skill_authoring_post_response_candidate',
],
)
async def test_set_query_var_keeps_plugin_business_variables_writable(self, mock_app, key):
runtime_handler = make_handler(mock_app)
mock_query = SimpleNamespace(variables={})
mock_app.query_pool.cached_queries['test-query'] = mock_query
response = await runtime_handler.actions[PluginToRuntimeAction.SET_QUERY_VAR.value](
{
'query_id': 'test-query',
'key': key,
'value': {'plugin': 'value'},
}
)
assert response.code == 0
assert mock_query.variables[key] == {'plugin': 'value'}
@pytest.mark.asyncio
@pytest.mark.parametrize('key', ['', None, 7])
async def test_set_query_var_rejects_invalid_key_shapes(self, mock_app, key):
runtime_handler = make_handler(mock_app)
mock_query = SimpleNamespace(variables={})
mock_app.query_pool.cached_queries['test-query'] = mock_query
response = await runtime_handler.actions[PluginToRuntimeAction.SET_QUERY_VAR.value](
{
'query_id': 'test-query',
'key': key,
'value': 'value',
}
)
assert response.code != 0
assert response.message == 'Query variable key must be a non-empty string'
assert mock_query.variables == {}
@pytest.mark.asyncio
async def test_get_query_var_success(self, mock_app):
"""Test get_query_var retrieves variable from query."""
@@ -205,10 +290,3 @@ class TestConstantsSemanticVersion:
assert hasattr(constants, 'edition')
assert constants.edition == 'community'
def test_required_database_version_exists(self):
"""Test database version constant."""
from langbot.pkg.utils import constants
assert hasattr(constants, 'required_database_version')
assert isinstance(constants.required_database_version, int)
+431 -91
View File
@@ -10,6 +10,8 @@ import pytest
from langbot_plugin.api.entities.builtin.provider import message as provider_message
from langbot_plugin.entities.io.actions.enums import PluginToRuntimeAction, RuntimeToLangBotAction
from langbot.pkg.provider.tools.errors import ToolExecutionDeniedError
def make_handler(app):
"""Create a RuntimeConnectionHandler with mocked external connection."""
@@ -456,6 +458,14 @@ class TestAgentRunProxyActions:
mock_app.model_mgr.get_rerank_model_by_uuid = AsyncMock()
mock_app.tool_mgr = Mock()
mock_app.tool_mgr.execute_func_call = AsyncMock(return_value={'ok': True})
mock_app.tool_mgr.get_tool_detail = AsyncMock(
return_value={
'name': 'test/search',
'description': 'Search test data',
'human_desc': 'Search',
'parameters': {'type': 'object'},
}
)
return mock_app
@staticmethod
@@ -463,9 +473,7 @@ class TestAgentRunProxyActions:
return SimpleNamespace(
pipeline_config={'output': {'misc': {'remove-think': remove_think}}},
variables={},
prompt=SimpleNamespace(
messages=[provider_message.Message(role='system', content='effective prompt')]
),
prompt=SimpleNamespace(messages=[provider_message.Message(role='system', content='effective prompt')]),
)
@pytest.mark.asyncio
@@ -491,10 +499,12 @@ class TestAgentRunProxyActions:
runtime_handler = make_handler(app)
try:
response = await runtime_handler.actions[PluginToRuntimeAction.GET_PROMPT.value]({
'run_id': run_id,
'caller_plugin_identity': 'test/runner',
})
response = await runtime_handler.actions[PluginToRuntimeAction.GET_PROMPT.value](
{
'run_id': run_id,
'caller_plugin_identity': 'test/runner',
}
)
finally:
await registry.unregister(run_id)
@@ -535,19 +545,23 @@ class TestAgentRunProxyActions:
runtime_handler = make_handler(app)
try:
response = await runtime_handler.actions[PluginToRuntimeAction.INVOKE_LLM.value]({
'run_id': run_id,
'caller_plugin_identity': 'test/runner',
'llm_model_uuid': 'llm_001',
'messages': [{'role': 'user', 'content': 'hello'}],
'funcs': [{
'name': 'search',
'human_desc': 'Search',
'description': 'Search',
'parameters': {'type': 'object'},
}],
'extra_args': {'temperature': 0.7, 'presence_penalty': 0.1},
})
response = await runtime_handler.actions[PluginToRuntimeAction.INVOKE_LLM.value](
{
'run_id': run_id,
'caller_plugin_identity': 'test/runner',
'llm_model_uuid': 'llm_001',
'messages': [{'role': 'user', 'content': 'hello'}],
'funcs': [
{
'name': 'search',
'human_desc': 'Search',
'description': 'Search',
'parameters': {'type': 'object'},
}
],
'extra_args': {'temperature': 0.7, 'presence_penalty': 0.1},
}
)
finally:
await registry.unregister(run_id)
@@ -603,12 +617,14 @@ class TestAgentRunProxyActions:
runtime_handler = make_handler(app)
try:
response = await runtime_handler.actions[PluginToRuntimeAction.INVOKE_LLM.value]({
'run_id': run_id,
'caller_plugin_identity': 'test/runner',
'llm_model_uuid': 'llm_usage_001',
'messages': [{'role': 'user', 'content': 'hello'}],
})
response = await runtime_handler.actions[PluginToRuntimeAction.INVOKE_LLM.value](
{
'run_id': run_id,
'caller_plugin_identity': 'test/runner',
'llm_model_uuid': 'llm_usage_001',
'messages': [{'role': 'user', 'content': 'hello'}],
}
)
finally:
await registry.unregister(run_id)
@@ -647,19 +663,23 @@ class TestAgentRunProxyActions:
runtime_handler = make_handler(app)
try:
response = await runtime_handler.actions[PluginToRuntimeAction.COUNT_TOKENS.value]({
'run_id': run_id,
'caller_plugin_identity': 'test/runner',
'llm_model_uuid': 'llm_count_001',
'messages': [{'role': 'user', 'content': 'hello'}],
'funcs': [{
'name': 'search',
'human_desc': 'Search',
'description': 'Search',
'parameters': {'type': 'object'},
}],
'extra_args': {'temperature': 0.7},
})
response = await runtime_handler.actions[PluginToRuntimeAction.COUNT_TOKENS.value](
{
'run_id': run_id,
'caller_plugin_identity': 'test/runner',
'llm_model_uuid': 'llm_count_001',
'messages': [{'role': 'user', 'content': 'hello'}],
'funcs': [
{
'name': 'search',
'human_desc': 'Search',
'description': 'Search',
'parameters': {'type': 'object'},
}
],
'extra_args': {'temperature': 0.7},
}
)
finally:
await registry.unregister(run_id)
@@ -692,12 +712,14 @@ class TestAgentRunProxyActions:
runtime_handler = make_handler(app)
try:
response = await runtime_handler.actions[PluginToRuntimeAction.COUNT_TOKENS.value]({
'run_id': run_id,
'caller_plugin_identity': 'test/runner',
'llm_model_uuid': 'llm_count_002',
'messages': [{'role': 'user', 'content': 'hello'}],
})
response = await runtime_handler.actions[PluginToRuntimeAction.COUNT_TOKENS.value](
{
'run_id': run_id,
'caller_plugin_identity': 'test/runner',
'llm_model_uuid': 'llm_count_002',
'messages': [{'role': 'user', 'content': 'hello'}],
}
)
finally:
await registry.unregister(run_id)
@@ -742,20 +764,24 @@ class TestAgentRunProxyActions:
responses = []
try:
stream = runtime_handler.actions[PluginToRuntimeAction.INVOKE_LLM_STREAM.value]({
'run_id': run_id,
'caller_plugin_identity': 'test/runner',
'llm_model_uuid': 'llm_stream_001',
'messages': [{'role': 'user', 'content': 'hello'}],
'funcs': [{
'name': 'search',
'human_desc': 'Search',
'description': 'Search',
'parameters': {'type': 'object'},
}],
'extra_args': {'max_tokens': 256},
'remove_think': True,
})
stream = runtime_handler.actions[PluginToRuntimeAction.INVOKE_LLM_STREAM.value](
{
'run_id': run_id,
'caller_plugin_identity': 'test/runner',
'llm_model_uuid': 'llm_stream_001',
'messages': [{'role': 'user', 'content': 'hello'}],
'funcs': [
{
'name': 'search',
'human_desc': 'Search',
'description': 'Search',
'parameters': {'type': 'object'},
}
],
'extra_args': {'max_tokens': 256},
'remove_think': True,
}
)
async for response in stream:
responses.append(response)
finally:
@@ -801,12 +827,14 @@ class TestAgentRunProxyActions:
responses = []
try:
stream = runtime_handler.actions[PluginToRuntimeAction.INVOKE_LLM_STREAM.value]({
'run_id': run_id,
'caller_plugin_identity': 'test/runner',
'llm_model_uuid': 'llm_stream_002',
'messages': [{'role': 'user', 'content': 'hello'}],
})
stream = runtime_handler.actions[PluginToRuntimeAction.INVOKE_LLM_STREAM.value](
{
'run_id': run_id,
'caller_plugin_identity': 'test/runner',
'llm_model_uuid': 'llm_stream_002',
'messages': [{'role': 'user', 'content': 'hello'}],
}
)
async for response in stream:
responses.append(response)
finally:
@@ -856,12 +884,14 @@ class TestAgentRunProxyActions:
responses = []
try:
stream = runtime_handler.actions[PluginToRuntimeAction.INVOKE_LLM_STREAM.value]({
'run_id': run_id,
'caller_plugin_identity': 'test/runner',
'llm_model_uuid': 'llm_stream_usage_001',
'messages': [{'role': 'user', 'content': 'hello'}],
})
stream = runtime_handler.actions[PluginToRuntimeAction.INVOKE_LLM_STREAM.value](
{
'run_id': run_id,
'caller_plugin_identity': 'test/runner',
'llm_model_uuid': 'llm_stream_usage_001',
'messages': [{'role': 'user', 'content': 'hello'}],
}
)
async for response in stream:
responses.append(response)
finally:
@@ -888,18 +918,28 @@ class TestAgentRunProxyActions:
runner_id='plugin:test/runner/default',
query_id=903,
plugin_identity='test/runner',
resources=make_agent_resources(tools=[{'tool_name': 'test/search'}]),
resources=make_agent_resources(
tools=[
{
'tool_name': 'test/search',
'source': 'mcp',
'source_id': 'bound-mcp',
}
]
),
)
runtime_handler = make_handler(app)
try:
response = await runtime_handler.actions[PluginToRuntimeAction.CALL_TOOL.value]({
'run_id': run_id,
'caller_plugin_identity': 'test/runner',
'tool_name': 'test/search',
'parameters': {'q': 'langbot'},
})
response = await runtime_handler.actions[PluginToRuntimeAction.CALL_TOOL.value](
{
'run_id': run_id,
'caller_plugin_identity': 'test/runner',
'tool_name': 'test/search',
'parameters': {'q': 'langbot'},
}
)
finally:
await registry.unregister(run_id)
@@ -909,8 +949,304 @@ class TestAgentRunProxyActions:
name='test/search',
parameters={'q': 'langbot'},
query=query,
source_ref={'source': 'mcp', 'source_id': 'bound-mcp'},
)
@pytest.mark.asyncio
async def test_get_tool_detail_passes_frozen_source_ref(self, app):
"""GET_TOOL_DETAIL resolves only the implementation frozen for the run."""
from langbot.pkg.agent.runner.session_registry import get_session_registry
run_id = 'run_proxy_get_tool_detail_source'
registry = get_session_registry()
await registry.unregister(run_id)
await registry.register(
run_id=run_id,
runner_id='plugin:test/runner/default',
query_id=None,
plugin_identity='test/runner',
resources=make_agent_resources(
tools=[
{
'tool_name': 'test/search',
'source': 'mcp',
'source_id': 'bound-mcp',
}
]
),
)
runtime_handler = make_handler(app)
try:
response = await runtime_handler.actions[PluginToRuntimeAction.GET_TOOL_DETAIL.value](
{
'run_id': run_id,
'caller_plugin_identity': 'test/runner',
'tool_name': 'test/search',
}
)
finally:
await registry.unregister(run_id)
assert response.code == 0
app.tool_mgr.get_tool_detail.assert_awaited_once_with(
'test/search',
source_ref={'source': 'mcp', 'source_id': 'bound-mcp'},
)
@pytest.mark.asyncio
@pytest.mark.parametrize(
('action', 'action_payload', 'manager_method'),
[
pytest.param(
PluginToRuntimeAction.CALL_TOOL,
{'parameters': {'q': 'langbot'}},
'execute_func_call',
id='call',
),
pytest.param(
PluginToRuntimeAction.GET_TOOL_DETAIL,
{},
'get_tool_detail',
id='detail',
),
],
)
@pytest.mark.parametrize(
'tool_resource',
[
pytest.param(
{'tool_name': 'test/search', 'source_id': 'bound-mcp'},
id='missing-source',
),
pytest.param(
{'tool_name': 'test/search', 'source': 'mcp'},
id='missing-source-id',
),
pytest.param(
{'tool_name': 'test/search', 'source': 7, 'source_id': 'bound-mcp'},
id='invalid-source-type',
),
pytest.param(
{'tool_name': 'test/search', 'source': 'mcp', 'source_id': 7},
id='invalid-source-id-type',
),
pytest.param(
{'tool_name': 'test/search', 'source': 'mcp', 'source_id': None},
id='unscoped-mcp-source',
),
pytest.param(
{'tool_name': 'test/search', 'source': 'plugin', 'source_id': None},
id='unscoped-plugin-source',
),
pytest.param(
{'tool_name': 'test/search', 'source': 'builtin', 'source_id': 'unexpected'},
id='builtin-source-id',
),
],
)
async def test_tool_actions_reject_malformed_frozen_source_identity(
self,
app,
action,
action_payload,
manager_method,
tool_resource,
):
"""Run-scoped tool actions never fall back from a malformed authorization snapshot."""
from langbot.pkg.agent.runner.session_registry import get_session_registry
run_id = f'run_proxy_malformed_source_{action.value}'
registry = get_session_registry()
await registry.unregister(run_id)
await registry.register(
run_id=run_id,
runner_id='plugin:test/runner/default',
query_id=None,
plugin_identity='test/runner',
resources=make_agent_resources(tools=[tool_resource]),
)
runtime_handler = make_handler(app)
try:
response = await runtime_handler.actions[action.value](
{
'run_id': run_id,
'caller_plugin_identity': 'test/runner',
'tool_name': 'test/search',
**action_payload,
}
)
finally:
await registry.unregister(run_id)
assert response.code != 0
assert response.message == 'Tool test/search has an invalid frozen source identity for this agent run'
getattr(app.tool_mgr, manager_method).assert_not_awaited()
@pytest.mark.asyncio
async def test_call_tool_returns_error_when_host_denies_execution(self, app):
"""CALL_TOOL preserves the existing error response when a loader denies execution."""
from langbot.pkg.agent.runner.session_registry import get_session_registry
run_id = 'run_proxy_call_tool_denied'
query = self.query()
app.query_pool.cached_queries[907] = query
app.tool_mgr.execute_func_call.side_effect = ToolExecutionDeniedError(
'langbot_mcp_read_resource',
'MCP resource agent reads are disabled',
)
registry = get_session_registry()
await registry.unregister(run_id)
await registry.register(
run_id=run_id,
runner_id='plugin:test/runner/default',
query_id=907,
plugin_identity='test/runner',
resources=make_agent_resources(
tools=[
{
'tool_name': 'langbot_mcp_read_resource',
'source': 'mcp',
'source_id': None,
}
]
),
)
runtime_handler = make_handler(app)
try:
response = await runtime_handler.actions[PluginToRuntimeAction.CALL_TOOL.value](
{
'run_id': run_id,
'caller_plugin_identity': 'test/runner',
'tool_name': 'langbot_mcp_read_resource',
'parameters': {'server_name': 'docs', 'uri': 'file:///README.md'},
}
)
finally:
await registry.unregister(run_id)
assert response.code != 0
assert 'Failed to execute tool langbot_mcp_read_resource' in response.message
assert 'MCP resource agent reads are disabled' in response.message
@pytest.mark.asyncio
async def test_call_tool_falls_back_to_host_execution_query(self, app):
"""Pure EBA runs use their Host-only Query when no cached Query exists."""
from langbot.pkg.agent.runner.session_registry import get_session_registry
run_id = 'run_proxy_call_tool_event_first'
query = self.query()
registry = get_session_registry()
await registry.unregister(run_id)
await registry.register(
run_id=run_id,
runner_id='plugin:test/runner/default',
query_id=None,
plugin_identity='test/runner',
resources=make_agent_resources(tools=[{'tool_name': 'exec', 'source': 'builtin', 'source_id': None}]),
execution_query=query,
)
runtime_handler = make_handler(app)
try:
response = await runtime_handler.actions[PluginToRuntimeAction.CALL_TOOL.value](
{
'run_id': run_id,
'caller_plugin_identity': 'test/runner',
'tool_name': 'exec',
'parameters': {'command': 'pwd'},
}
)
finally:
await registry.unregister(run_id)
assert response.code == 0
assert getattr(query, '_agent_run_session')['run_id'] == run_id
app.tool_mgr.execute_func_call.assert_awaited_once_with(
name='exec',
parameters={'command': 'pwd'},
query=query,
source_ref={'source': 'builtin', 'source_id': None},
)
@pytest.mark.asyncio
async def test_pure_event_call_tool_exec_uses_native_host_path(self, app):
"""A pure EBA run can call authorized native exec through CALL_TOOL."""
from langbot.pkg.agent.runner.execution_context import build_execution_query
from langbot.pkg.agent.runner.host_models import AgentEventEnvelope
from langbot.pkg.agent.runner.session_registry import get_session_registry
from langbot.pkg.provider.tools.loaders.native import NativeToolLoader
from langbot.pkg.provider.tools.toolmgr import ToolManager
from langbot_plugin.api.entities.builtin.agent_runner.delivery import DeliveryContext
from langbot_plugin.api.entities.builtin.agent_runner.input import AgentInput
event = AgentEventEnvelope(
event_id='event-native-exec',
event_type='message.received',
source='platform',
bot_id='bot-1',
conversation_id='person_user-1',
input=AgentInput(text='run pwd'),
delivery=DeliveryContext(
surface='platform',
reply_target={'target_type': 'person', 'target_id': 'user-1'},
platform_capabilities={'adapter': 'TestAdapter'},
),
)
query = build_execution_query(event, [])
app.box_service = SimpleNamespace(
execute_tool=AsyncMock(
return_value={
'ok': True,
'session_id': 'lb-box-test',
'stdout': '/workspace\n',
'stderr': '',
}
),
)
app.monitoring_service = None
app.skill_mgr = None
native_loader = NativeToolLoader(app)
native_loader._backend_available = True
tool_mgr = ToolManager(app)
tool_mgr.native_tool_loader = native_loader
tool_mgr.plugin_tool_loader = Mock()
tool_mgr.mcp_tool_loader = Mock()
tool_mgr.skill_tool_loader = Mock()
app.tool_mgr = tool_mgr
run_id = 'run_pure_event_native_exec'
registry = get_session_registry()
await registry.unregister(run_id)
await registry.register(
run_id=run_id,
runner_id='plugin:test/runner/default',
query_id=None,
plugin_identity='test/runner',
resources=make_agent_resources(tools=[{'tool_name': 'exec', 'source': 'builtin', 'source_id': None}]),
execution_query=query,
)
runtime_handler = make_handler(app)
try:
response = await runtime_handler.actions[PluginToRuntimeAction.CALL_TOOL.value](
{
'run_id': run_id,
'caller_plugin_identity': 'test/runner',
'tool_name': 'exec',
'parameters': {'command': 'pwd'},
}
)
finally:
await registry.unregister(run_id)
assert response.code == 0
assert response.data['result']['ok'] is True
assert response.data['result']['stdout'] == '/workspace\n'
app.box_service.execute_tool.assert_awaited_once_with({'command': 'pwd'}, query)
@pytest.mark.asyncio
async def test_invoke_rerank_uses_authorized_model_and_extra_args(self, app):
"""INVOKE_RERANK validates run-scoped model access and merges model extra_args."""
@@ -928,10 +1264,12 @@ class TestAgentRunProxyActions:
)
provider = SimpleNamespace(
invoke_rerank=AsyncMock(return_value=[
{'index': 0, 'relevance_score': 0.2},
{'index': 1, 'relevance_score': 0.9},
]),
invoke_rerank=AsyncMock(
return_value=[
{'index': 0, 'relevance_score': 0.2},
{'index': 1, 'relevance_score': 0.9},
]
),
)
rerank_model = SimpleNamespace(
model_entity=SimpleNamespace(extra_args={'top_n': 5, 'return_documents': False}),
@@ -941,15 +1279,17 @@ class TestAgentRunProxyActions:
runtime_handler = make_handler(app)
try:
response = await runtime_handler.actions[PluginToRuntimeAction.INVOKE_RERANK.value]({
'run_id': run_id,
'caller_plugin_identity': 'test/runner',
'rerank_model_uuid': 'rerank_001',
'query': 'hello',
'documents': ['a', 'b'],
'top_k': 1,
'extra_args': {'top_n': 2},
})
response = await runtime_handler.actions[PluginToRuntimeAction.INVOKE_RERANK.value](
{
'run_id': run_id,
'caller_plugin_identity': 'test/runner',
'rerank_model_uuid': 'rerank_001',
'query': 'hello',
'documents': ['a', 'b'],
'top_k': 1,
'extra_args': {'top_n': 2},
}
)
finally:
await registry.unregister(run_id)