feat(runner): authorize contextual platform APIs

This commit is contained in:
RockChinQ
2026-09-11 00:02:44 +08:00
parent f24a7c9bb2
commit d93e44d82d
5 changed files with 307 additions and 7 deletions
+71 -4
View File
@@ -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)
+59 -2
View File
@@ -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,