mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-09-16 14:57:15 +00:00
feat(runner): authorize contextual platform APIs
This commit is contained in:
@@ -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')))
|
||||
""")
|
||||
)
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user