From d93e44d82dc9c1b2199dafd1a297ddd031eb0fc6 Mon Sep 17 00:00:00 2001 From: RockChinQ Date: Fri, 11 Sep 2026 00:02:44 +0800 Subject: [PATCH] feat(runner): authorize contextual platform APIs --- .../pkg/agent/runner/platform_tools.py | 75 +++++++++++++- src/langbot/pkg/plugin/handler.py | 61 +++++++++++- tests/e2e/test_runner_plugin_runtime.py | 4 +- tests/unit_tests/agent/test_platform_tools.py | 76 ++++++++++++++ .../unit_tests/plugin/test_handler_actions.py | 98 +++++++++++++++++++ 5 files changed, 307 insertions(+), 7 deletions(-) diff --git a/src/langbot/pkg/agent/runner/platform_tools.py b/src/langbot/pkg/agent/runner/platform_tools.py index 6f841572e..c3ba18458 100644 --- a/src/langbot/pkg/agent/runner/platform_tools.py +++ b/src/langbot/pkg/agent/runner/platform_tools.py @@ -604,12 +604,76 @@ def _normalize_platform_params( return dict(parameters) +def resolve_platform_api_call(session, bot_uuid, action, params, context_tool=None): + """Resolve an adapter call against Host-frozen tool grants and event targets.""" + authorization = session.get('authorization') or {} + context = authorization.get('platform_context') or {} + if context_tool is None and (not bot_uuid or bot_uuid != authorization.get('bot_id')): + raise ValueError('Platform API bot is not authorized for this run') + if context_tool is not None and context_tool != 'event_reply': + raise ValueError('Unknown context platform API') + params = dict(params) + message = None + quote_origin = False + if action == 'send_message': + message = platform_message.MessageChain.model_validate(params.pop('message')) + if not message.root: + raise ValueError('Message must not be empty') + quote_origin = params.pop('quote_origin', False) + if not isinstance(quote_origin, bool): + raise ValueError('quote_origin must be a boolean') + if quote_origin and context_tool != 'event_reply': + raise ValueError('quote_origin requires the current event context') + # Only text is used for tool-schema validation. The original chain is delivered intact. + params['text'] = ''.join(c.text for c in message.root if isinstance(c, platform_message.Plain)) or '[message]' + granted = { + tool['tool_name'] + for tool in authorization.get('resources', {}).get('tools', []) + if tool.get('source') == 'platform' and (not tool.get('operations') or 'call' in tool['operations']) + } + for definition in PLATFORM_TOOL_DEFINITIONS: + if definition.name not in granted or definition.api != action: + continue + if context_tool is not None and definition.name != context_tool: + continue + properties = definition.parameters.get('properties') or {} + tool_params = {key: value for key, value in params.items() if key in properties} + try: + normalized = _normalize_platform_params(definition, tool_params) + bound = _event_params(definition, context, normalized) if definition.scope == 'event' else normalized + except ValueError: + continue + if context_tool is None and bound != params: + continue + if context_tool is not None and set(params) - set(properties): + raise ValueError('Unexpected context API parameters') + if message is not None and quote_origin: + target = (context.get('delivery') or {}).get('reply_target') or {} + if not target.get('message_id'): + raise ValueError('The current event has no message to quote') + message = platform_message.MessageChain( + [ + platform_message.Quote( + id=target['message_id'], + group_id=target.get('group_id'), + sender_id=(context.get('actor') or {}).get('actor_id'), + target_id=target.get('target_id'), + origin=platform_message.MessageChain([]), + ), + *message.root, + ] + ) + return definition.name, tool_params, message + raise ValueError(f'Platform API {action} or its target is not authorized for this run') + + async def execute_platform_tool( ap: typing.Any, execution_context: typing.Any, session: typing.Mapping[str, typing.Any], tool_name: str, parameters: dict[str, typing.Any], + message_chain: platform_message.MessageChain | None = None, ) -> typing.Any: definition = PLATFORM_TOOLS_BY_NAME.get(tool_name) if definition is None: @@ -622,7 +686,10 @@ async def execute_platform_tool( normalized = _event_params(definition, context, normalized) # This flag is frozen by the Host from the synthetic debug envelope, not tool arguments. if delivery.get('surface') == 'webui' and (delivery.get('platform_capabilities') or {}).get('debug_mock') is True: - return _execute_mock_platform_tool(definition, context, normalized) + result = _execute_mock_platform_tool(definition, context, normalized) + if message_chain is not None: + result['parameters']['message'] = message_chain.model_dump(mode='json') + return result bot_id = authorization.get('bot_id') if not bot_id: raise ValueError('This run is not associated with a platform bot') @@ -638,9 +705,9 @@ async def execute_platform_tool( normalized = { 'target_type': _require_string(normalized, 'target_type'), 'target_id': _require_string(normalized, 'target_id'), - 'message': platform_message.MessageChain( - [platform_message.Plain(text=_require_string(normalized, 'text'))] - ), + 'message': message_chain + if message_chain is not None + else platform_message.MessageChain([platform_message.Plain(text=_require_string(normalized, 'text'))]), } return await api_func(**normalized) diff --git a/src/langbot/pkg/plugin/handler.py b/src/langbot/pkg/plugin/handler.py index edba2653d..9d9c55cc7 100644 --- a/src/langbot/pkg/plugin/handler.py +++ b/src/langbot/pkg/plugin/handler.py @@ -52,7 +52,7 @@ from ..utils import constants from ..agent.runner.session_registry import get_session_registry from ..agent.runner.config_resolver import RunnerConfigResolver from ..agent.runner import config_schema -from ..agent.runner.platform_tools import execute_platform_tool, get_platform_tool_detail +from ..agent.runner.platform_tools import execute_platform_tool, get_platform_tool_detail, resolve_platform_api_call from ..pipeline.pool import get_query_execution_context @@ -1168,9 +1168,52 @@ class RuntimeConnectionHandler(handler.Handler): }, ) + async def call_run_platform_api(data): + action_context, _ = await self._require_plugin_action_context() + session, error = await _validate_agent_run_session( + data['run_id'], data.get('caller_plugin_identity'), self.ap, 'call_platform_api' + ) + if error: + return error + try: + tool_name, parameters, message = resolve_platform_api_call( + session, data.get('bot_uuid'), data['action'], data.get('params') or {}, data.get('context_tool') + ) + session, error = await _validate_run_authorization( + data['run_id'], 'tool', tool_name, self.ap, data.get('caller_plugin_identity'), operation='call' + ) + if error: + return error + _, error = _validate_frozen_tool_source_identity(session, tool_name, self.ap) + if error: + return error + result = await execute_platform_tool( + self.ap, + self._execution_context(action_context), + session, + tool_name, + parameters, + message_chain=message, + ) + return handler.ActionResponse.success(data={'result': _serialize_plugin_api_result(result)}) + except (ValueError, KeyError, TypeError) as exc: + return handler.ActionResponse.error(message=str(exc)) + @self.action(PluginToRuntimeAction.SEND_MESSAGE) async def send_message(data: dict[str, Any]) -> handler.ActionResponse: """Send message""" + if data.get('run_id') is not None: + return await call_run_platform_api( + { + **data, + 'action': 'send_message', + 'params': { + 'target_type': data['target_type'], + 'target_id': data['target_id'], + 'message': data['message_chain'], + }, + } + ) action_context, _ = await self._require_plugin_action_context() execution_context = self._execution_context(action_context) bot_uuid = data['bot_uuid'] @@ -1215,11 +1258,14 @@ class RuntimeConnectionHandler(handler.Handler): @self.action(PluginToRuntimeAction.CALL_PLATFORM_API) async def call_platform_api(data: dict[str, Any]) -> handler.ActionResponse: """Call a platform adapter API""" + if data.get('run_id') is not None: + return await call_run_platform_api(data) + action_context, _ = await self._require_plugin_action_context() bot_uuid = data['bot_uuid'] action = data['action'] params = data.get('params') or {} - bot = await self.ap.platform_mgr.get_bot_by_uuid(bot_uuid) + bot = await self.ap.platform_mgr.get_bot_by_uuid(self._execution_context(action_context), bot_uuid) if bot is None: return handler.ActionResponse.error( message=f'Bot with bot_uuid {bot_uuid} not found', @@ -2024,6 +2070,17 @@ class RuntimeConnectionHandler(handler.Handler): action_context, _ = await self._require_plugin_action_context() embedding_model_uuid = data['embedding_model_uuid'] texts = data['texts'] + if data.get('run_id') is not None: + _, error = await _validate_run_authorization( + data['run_id'], + 'model', + embedding_model_uuid, + self.ap, + data.get('caller_plugin_identity'), + operation='invoke', + ) + if error: + return error if not await self._resource_exists( persistence_model.EmbeddingModel, diff --git a/tests/e2e/test_runner_plugin_runtime.py b/tests/e2e/test_runner_plugin_runtime.py index b3865b93b..50353778b 100644 --- a/tests/e2e/test_runner_plugin_runtime.py +++ b/tests/e2e/test_runner_plugin_runtime.py @@ -194,7 +194,9 @@ def _write_qa_runner_plugin(plugin_root: Path) -> None: @self.handler(MemberJoinedEvent) async def handle(ctx: RunnerContext): await ctx.log('Handling ' + str(ctx.platform_event.member.id)) - result = await ctx.reply(ctx.config['greeting'] + ', ' + (ctx.platform_event.member.nickname or 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'))) """) ) diff --git a/tests/unit_tests/agent/test_platform_tools.py b/tests/unit_tests/agent/test_platform_tools.py index 3078ecfbe..a4dbf3190 100644 --- a/tests/unit_tests/agent/test_platform_tools.py +++ b/tests/unit_tests/agent/test_platform_tools.py @@ -370,3 +370,79 @@ async def test_platform_action_rejects_parameters_outside_the_declared_schema() ) adapter.get_group_info.assert_not_awaited() + + +def api_session(names): + event = _event('message.received') + event.delivery.surface = 'webui' + event.delivery.platform_capabilities['debug_mock'] = True + resources, _ = build_platform_tool_resources(event, names, ['call']) + return { + 'authorization': { + 'bot_id': 'bot-1', + 'platform_context': freeze_platform_context(event), + 'resources': {'tools': resources}, + } + } + + +@pytest.mark.parametrize( + 'bot,params', + [ + ('other-bot', {'group_id': 'group-1'}), + ('bot-1', {'group_id': 'other-group'}), + ('bot-1', {'group_id': 'group-1', 'unknown': 'field'}), + ], +) +def test_common_platform_api_cannot_expand_event_grant(bot, params): + from langbot.pkg.agent.runner.platform_tools import resolve_platform_api_call + + with pytest.raises(ValueError, match='not authorized'): + resolve_platform_api_call(api_session(['event_get_group']), bot, 'get_group_info', params) + + +def test_common_platform_api_resolves_only_granted_call_operations(): + from langbot.pkg.agent.runner.platform_tools import resolve_platform_api_call + + session = api_session(['event_get_group']) + assert resolve_platform_api_call(session, 'bot-1', 'get_group_info', {'group_id': 'group-1'})[:2] == ( + 'event_get_group', + {}, + ) + session['authorization']['resources']['tools'][0]['operations'] = ['detail'] + with pytest.raises(ValueError, match='not authorized'): + resolve_platform_api_call(session, 'bot-1', 'get_group_info', {'group_id': 'group-1'}) + session = api_session(['platform_get_group_info']) + assert ( + resolve_platform_api_call(session, 'bot-1', 'get_group_info', {'group_id': 'other'})[0] + == 'platform_get_group_info' + ) + + +@pytest.mark.asyncio +async def test_rich_context_reply_preserves_chain_quote_and_mock(): + from langbot.pkg.agent.runner.platform_tools import resolve_platform_api_call + + chain = platform_message.MessageChain( + [platform_message.At(target='user-1'), platform_message.Plain(text='welcome')] + ) + session = api_session(['event_reply']) + name, params, rich = resolve_platform_api_call( + session, None, 'send_message', {'message': chain.model_dump(), 'quote_origin': True}, 'event_reply' + ) + assert rich.root[0].id == 'message-1' + assert rich.root[1:] == chain.root + ap = SimpleNamespace( + platform_mgr=SimpleNamespace(get_bot_by_uuid=AsyncMock(side_effect=AssertionError('real send'))) + ) + result = await execute_platform_tool(ap, object(), session, name, params, message_chain=rich) + assert result['mock'] is True + assert result['parameters']['target_id'] == 'group-1' + assert result['parameters']['message'][1]['type'] == 'At' + with pytest.raises(ValueError, match='Unexpected'): + resolve_platform_api_call( + session, None, 'send_message', {'message': chain.model_dump(), 'target_id': 'other'}, 'event_reply' + ) + session['authorization']['resources']['tools'] = [] + with pytest.raises(ValueError, match='not authorized'): + resolve_platform_api_call(session, None, 'send_message', {'message': chain.model_dump()}, 'event_reply') diff --git a/tests/unit_tests/plugin/test_handler_actions.py b/tests/unit_tests/plugin/test_handler_actions.py index 4964b1104..595e763a4 100644 --- a/tests/unit_tests/plugin/test_handler_actions.py +++ b/tests/unit_tests/plugin/test_handler_actions.py @@ -1766,3 +1766,101 @@ async def test_reply_stream_authorization_is_run_and_workspace_scoped(case): streams.apply.assert_not_awaited() finally: await registry.unregister(run_id) + + +@pytest.mark.asyncio +@pytest.mark.parametrize('mode', ['allowed', 'disabled', 'wrong-target', 'wrong-plugin', 'expired', 'raw-send']) +async def test_public_platform_api_preserves_runner_authorization_and_mock(mode): + app = Mock() + app.logger = Mock() + from langbot.pkg.agent.runner.session_registry import get_session_registry + from langbot.pkg.agent.runner.platform_tools import freeze_platform_context, build_platform_tool_resources + from langbot.pkg.agent.runner.host_models import AgentEventEnvelope + from langbot_plugin.api.entities.builtin.runner import AgentInput, DeliveryContext + + event = AgentEventEnvelope( + event_id='api-event', + event_type='message.received', + source='webui', + bot_id='bot', + input=AgentInput(text='hello'), + delivery=DeliveryContext( + surface='webui', + reply_target={'target_type': 'group', 'target_id': 'group'}, + platform_capabilities={'debug_mock': True, 'supported_apis': ['send_message']}, + ), + ) + tools, _ = build_platform_tool_resources(event, [] if mode == 'disabled' else ['event_reply'], ['call']) + registry = get_session_registry() + run_id = 'public-platform-' + mode + await registry.register( + run_id=run_id, + runner_id='plugin:test-author/test-plugin/runner', + query_id=None, + plugin_identity='other/plugin' if mode == 'wrong-plugin' else 'test-author/test-plugin', + resources=make_agent_resources(tools=tools), + bot_id='bot', + platform_context=freeze_platform_context(event), + ) + runtime_handler = make_handler(app) + app.platform_mgr.get_bot_by_uuid = AsyncMock(side_effect=AssertionError('Mock must not send')) + data = { + 'run_id': run_id, + 'bot_uuid': 'bot', + 'action': 'send_message', + 'params': { + 'target_type': 'group', + 'target_id': 'other' if mode == 'wrong-target' else 'group', + 'message': [{'type': 'Plain', 'text': 'hello'}], + }, + } + action = PluginToRuntimeAction.CALL_PLATFORM_API + if mode == 'raw-send': + action = PluginToRuntimeAction.SEND_MESSAGE + data = { + 'run_id': run_id, + 'bot_uuid': 'bot', + 'target_type': 'group', + 'target_id': 'group', + 'message_chain': data['params']['message'], + } + if mode == 'expired': + await registry.unregister(run_id) + try: + result = await runtime_handler.actions[action.value](data) + finally: + await registry.unregister(run_id) + if mode in ('allowed', 'raw-send'): + assert result.code == 0, result.message + assert result.data['result']['mock'] is True + else: + assert result.code != 0 + app.platform_mgr.get_bot_by_uuid.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_embedding_api_rejects_model_outside_runner_grants(): + from langbot.pkg.agent.runner.session_registry import get_session_registry + + app = Mock() + app.logger = Mock() + app.model_mgr.get_embedding_model_by_uuid = AsyncMock() + runtime_handler = make_handler(app) + registry = get_session_registry() + run_id = 'embedding-ungranted' + await registry.register( + run_id=run_id, + runner_id='plugin:test-author/test-plugin/runner', + query_id=None, + plugin_identity='test-author/test-plugin', + resources=make_agent_resources(), + ) + try: + response = await runtime_handler.actions[PluginToRuntimeAction.INVOKE_EMBEDDING.value]( + {'run_id': run_id, 'embedding_model_uuid': 'outside-grants', 'texts': ['hello']} + ) + finally: + await registry.unregister(run_id) + assert response.code != 0 + assert 'not authorized' in response.message + app.model_mgr.get_embedding_model_by_uuid.assert_not_awaited()