mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-09-27 03:46:39 +08:00
feat: delegate sandbox policy to runners and simplify pipeline migration
This commit is contained in:
@@ -2,7 +2,6 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
|
||||
from langbot_plugin.api.entities.builtin.runner.delivery import DeliveryContext
|
||||
from langbot_plugin.api.entities.builtin.runner.input import AgentInput
|
||||
@@ -12,13 +11,10 @@ from langbot_plugin.api.entities.builtin.provider.message import ContentElement
|
||||
from langbot.pkg.agent.runner.execution_context import (
|
||||
append_mcp_resource_context_to_event,
|
||||
build_execution_query,
|
||||
build_host_box_scope,
|
||||
prepare_box_scope,
|
||||
prepare_execution_query,
|
||||
project_mcp_resource_config,
|
||||
)
|
||||
from langbot.pkg.agent.runner.host_models import AgentEventEnvelope
|
||||
from langbot.pkg.utils import constants
|
||||
|
||||
|
||||
class PlatformAdapter:
|
||||
@@ -54,79 +50,14 @@ def make_event(
|
||||
)
|
||||
|
||||
|
||||
def test_pipeline_and_event_execution_use_same_platform_session_scope(monkeypatch):
|
||||
monkeypatch.setattr(constants, 'instance_id', 'instance-1')
|
||||
def test_query_preparation_does_not_choose_a_box():
|
||||
event = make_event()
|
||||
query = pipeline_query.Query.model_construct(
|
||||
query_id=1,
|
||||
launcher_type='person',
|
||||
launcher_id='user-1',
|
||||
sender_id='user-1',
|
||||
adapter=PlatformAdapter(),
|
||||
variables={},
|
||||
)
|
||||
|
||||
query = pipeline_query.Query.model_construct(variables={})
|
||||
prepare_execution_query(query, event, ['pdf'])
|
||||
event_query = build_execution_query(event, ['pdf'])
|
||||
|
||||
assert query.variables['_host_box_scope'] == event_query.variables['_host_box_scope']
|
||||
assert query.variables['_pipeline_bound_skills'] == ['pdf']
|
||||
assert event_query.variables['_pipeline_bound_skills'] == ['pdf']
|
||||
scope = json.loads(query.variables['_host_box_scope'])
|
||||
assert scope == {
|
||||
'instance_id': 'instance-1',
|
||||
'workspace_id': 'workspace-1',
|
||||
'bot_id': 'bot-1',
|
||||
'platform_adapter': 'PlatformAdapter',
|
||||
'target_type': 'person',
|
||||
'target_id': 'user-1',
|
||||
'thread_id': None,
|
||||
}
|
||||
|
||||
|
||||
def test_prepare_box_scope_does_not_change_existing_skill_projection():
|
||||
event = make_event()
|
||||
query = pipeline_query.Query.model_construct(
|
||||
query_id=1,
|
||||
launcher_type='person',
|
||||
launcher_id='user-1',
|
||||
variables={'_pipeline_bound_skills': ['existing']},
|
||||
)
|
||||
|
||||
variables = prepare_box_scope(query, event)
|
||||
|
||||
assert variables['_host_box_scope']
|
||||
assert variables['_pipeline_bound_skills'] == ['existing']
|
||||
|
||||
|
||||
def test_prepare_box_scope_preserves_event_first_channel_scope():
|
||||
channel_event = make_event(target_type='channel', target_id='same')
|
||||
channel_query = build_execution_query(channel_event, [])
|
||||
original_scope = channel_query.variables['_host_box_scope']
|
||||
|
||||
prepare_execution_query(channel_query, channel_event, [])
|
||||
|
||||
person_scope = build_execution_query(make_event(target_type='person', target_id='same'), []).variables[
|
||||
'_host_box_scope'
|
||||
]
|
||||
assert channel_query.variables['_host_box_scope'] == original_scope
|
||||
assert json.loads(original_scope)['target_type'] == 'channel'
|
||||
assert original_scope != person_scope
|
||||
|
||||
|
||||
def test_prepare_box_scope_overwrites_untrusted_existing_scope():
|
||||
event = make_event(target_type='person', target_id='user-1')
|
||||
query = pipeline_query.Query.model_construct(
|
||||
query_id=1,
|
||||
launcher_type='person',
|
||||
launcher_id='user-1',
|
||||
variables={'_host_box_scope': 'forged-scope'},
|
||||
)
|
||||
|
||||
variables = prepare_box_scope(query, event)
|
||||
|
||||
assert variables['_host_box_scope'] != 'forged-scope'
|
||||
assert json.loads(variables['_host_box_scope'])['target_id'] == 'user-1'
|
||||
assert query.variables == {'_pipeline_bound_skills': ['pdf']}
|
||||
assert event_query.variables == {'_pipeline_bound_skills': ['pdf']}
|
||||
assert getattr(query, '_box_binding', None) is None
|
||||
|
||||
|
||||
def test_project_mcp_resource_config_uses_independent_runner_settings():
|
||||
@@ -187,10 +118,7 @@ def test_event_reply_target_populates_valid_session_identity():
|
||||
assert query.sender_id == 'room-1'
|
||||
assert query.session.launcher_type.value == 'group'
|
||||
assert query.session.launcher_id == 'room-1'
|
||||
scope = json.loads(query.variables['_host_box_scope'])
|
||||
assert scope['target_type'] == 'group'
|
||||
assert scope['target_id'] == 'room-1'
|
||||
assert 'rotating-transcript-id' not in query.variables['_host_box_scope']
|
||||
assert '_host_box_scope' not in query.variables
|
||||
|
||||
|
||||
def test_non_message_event_without_conversation_uses_event_scope():
|
||||
@@ -205,21 +133,6 @@ def test_non_message_event_without_conversation_uses_event_scope():
|
||||
|
||||
query = build_execution_query(event, [])
|
||||
|
||||
scope = json.loads(query.variables['_host_box_scope'])
|
||||
assert scope['target_type'] == 'event'
|
||||
assert scope['target_id'] == event.event_id
|
||||
assert '_host_box_scope' not in query.variables
|
||||
assert query.pipeline_config is None
|
||||
assert query.pipeline_uuid is None
|
||||
|
||||
|
||||
def test_scope_isolated_by_instance_and_platform_adapter(monkeypatch):
|
||||
event = make_event(adapter='AdapterA')
|
||||
monkeypatch.setattr(constants, 'instance_id', 'instance-a')
|
||||
first = build_host_box_scope(event)
|
||||
|
||||
monkeypatch.setattr(constants, 'instance_id', 'instance-b')
|
||||
second = build_host_box_scope(event)
|
||||
other_adapter = build_host_box_scope(make_event(adapter='AdapterB'))
|
||||
|
||||
assert first != second
|
||||
assert second != other_adapter
|
||||
|
||||
@@ -927,7 +927,7 @@ class TestQueryEntrySessionQueryId:
|
||||
"""Tests for internal query_id entering session registry."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_query_box_scope_exists_before_attachment_materialization(self, clean_agent_state):
|
||||
async def test_query_entry_does_not_select_box_or_materialize_attachments(self, clean_agent_state):
|
||||
"""Inbound staging and later runner tools resolve to the same Box session."""
|
||||
from langbot.pkg.box.service import BoxService
|
||||
|
||||
@@ -965,9 +965,9 @@ class TestQueryEntrySessionQueryId:
|
||||
session = plugin_connector.sessions_during_run[0]
|
||||
assert session is not None
|
||||
assert session['execution_query'] is query
|
||||
runner_session_id = box_service.resolver.resolve_box_session_id(session['execution_query'])
|
||||
assert box_service.materialize_session_id == runner_session_id
|
||||
assert query.variables['_host_box_scope']
|
||||
assert box_service.materialize_session_id is None
|
||||
assert '_host_box_scope' not in query.variables
|
||||
assert getattr(query, '_box_binding', None) is None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_query_id_registered_in_session_for_query_entry_flow(self, clean_agent_state):
|
||||
@@ -1112,7 +1112,7 @@ class TestQueryEntrySessionQueryId:
|
||||
assert execution_query.sender_id == event.conversation_id
|
||||
assert execution_query.session.launcher_id == event.conversation_id
|
||||
assert execution_query.message_event.type == event.event_type
|
||||
assert execution_query.variables['_host_box_scope']
|
||||
assert '_host_box_scope' not in execution_query.variables
|
||||
assert execution_query.variables['_pipeline_bound_skills'] == ['demo', 'hidden']
|
||||
assert execution_query.variables['_pipeline_mcp_resource_attachments'][0]['server_uuid'] == 'srv-1'
|
||||
assert execution_query.variables['_pipeline_mcp_resource_agent_read_enabled'] is True
|
||||
@@ -1543,3 +1543,28 @@ async def test_beta_diagnostics_real_runner_terminal(clean_agent_state, terminal
|
||||
import json
|
||||
|
||||
assert 'CANARY' not in json.dumps(ap.diagnostics.pending)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_agent_cannot_deliver_exported_files_without_reply_api(clean_agent_state):
|
||||
"""Returning file handles must not bypass the Agent's reply authorization."""
|
||||
connector = FakePluginConnector(
|
||||
results=[
|
||||
{
|
||||
'type': 'message.completed',
|
||||
'data': {'message': {'role': 'assistant', 'content': 'file'}, 'file_ids': ['exported-file']},
|
||||
}
|
||||
]
|
||||
)
|
||||
orchestrator = AgentRunOrchestrator(FakeApplication(connector, clean_agent_state), FakeRegistry(make_descriptor()))
|
||||
query = make_query()
|
||||
plan = orchestrator.query_bridge.build_plan(query)
|
||||
plan.binding.processor_type = 'agent'
|
||||
with pytest.raises(ValueError, match='sent explicitly'):
|
||||
[
|
||||
m
|
||||
async for m in orchestrator.run(
|
||||
plan.event, plan.binding, adapter_context={'_execution_context': TEST_CONTEXT, '_query': query}
|
||||
)
|
||||
]
|
||||
assert await get_session_registry().list_active_runs() == []
|
||||
|
||||
@@ -732,3 +732,135 @@ async def test_cancel_during_prepare_keeps_original_and_stops_batch(env):
|
||||
)
|
||||
env.ap.pipeline_mgr.prepare_pipeline.assert_awaited_once()
|
||||
env.ap.pipeline_mgr.publish_pipeline.assert_not_called()
|
||||
|
||||
|
||||
async def execute_all(env, install_plugins=True):
|
||||
response = await env.svc.execute(context(), {'confirmed': True, 'all': True, 'install_plugins': install_plugins})
|
||||
task = env.ap.task_mgr.get_task_by_id(response['task_id'])
|
||||
await task.task
|
||||
return response, task.task_context.metadata
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_all_mode_migrates_workspace_and_skips_completed_on_retry(env):
|
||||
response, metadata = await execute_all(env)
|
||||
assert response['pipeline_uuids'] == ['one', 'two']
|
||||
assert [r['state'] for r in metadata['results']] == ['migrated', 'migrated']
|
||||
configs, backups = await rows(env)
|
||||
assert configs['foreign'] == SOURCE
|
||||
assert len(backups) == 2
|
||||
with pytest.raises(env.m.MigrationError, match='nothing_to_migrate'):
|
||||
await execute_all(env)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_all_data_only_without_plugins_never_contacts_runtime_or_marketplace(env):
|
||||
async with env.engine.begin() as conn:
|
||||
await conn.execute(sa.delete(PluginSetting))
|
||||
env.ap.runner_registry.list_runners.side_effect = AssertionError('offline must not query runtime')
|
||||
env.ap.plugin_connector.install_plugin = AsyncMock(side_effect=AssertionError('offline must not install'))
|
||||
_, metadata = await execute_all(env, install_plugins=False)
|
||||
assert [r['state'] for r in metadata['results']] == ['migrated', 'migrated']
|
||||
assert all(r['code'] == 'data_only' for r in metadata['results'])
|
||||
configs, backups = await rows(env)
|
||||
assert len(backups) == 2
|
||||
assert configs['one']['ai']['runner']['id'] == RID
|
||||
assert configs['foreign'] == SOURCE
|
||||
env.ap.plugin_connector.install_plugin.assert_not_awaited()
|
||||
env.ap.runner_registry.list_runners.assert_not_awaited()
|
||||
assert env.ap.pipeline_mgr.publish_pipeline.call_count == 2
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_all_mode_installs_missing_plugin_once_then_migrates_both(env):
|
||||
async with env.engine.begin() as conn:
|
||||
await conn.execute(sa.delete(PluginSetting))
|
||||
|
||||
async def install(source, info, task_context):
|
||||
assert info['plugin_version'] == '1.0'
|
||||
async with env.engine.begin() as conn:
|
||||
await conn.execute(
|
||||
sa.insert(PluginSetting).values(
|
||||
workspace_uuid=WS, plugin_author='langbot-team', plugin_name='TestAgent', enabled=True
|
||||
)
|
||||
)
|
||||
|
||||
env.ap.plugin_connector.install_plugin = AsyncMock(side_effect=install)
|
||||
_, metadata = await execute_all(env)
|
||||
assert [r['state'] for r in metadata['results']] == ['migrated', 'migrated']
|
||||
env.ap.plugin_connector.install_plugin.assert_awaited_once()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_all_install_failure_preserves_sources_and_never_exposes_upstream_error(env):
|
||||
async with env.engine.begin() as conn:
|
||||
await conn.execute(sa.delete(PluginSetting))
|
||||
env.ap.plugin_connector.install_plugin = AsyncMock(side_effect=RuntimeError('secret-token'))
|
||||
_, metadata = await execute_all(env)
|
||||
assert all(r['code'] == 'plugin_install_failed' for r in metadata['results'])
|
||||
assert 'secret-token' not in str(metadata)
|
||||
configs, backups = await rows(env)
|
||||
assert configs['one'] == SOURCE and not backups
|
||||
env.ap.plugin_connector.install_plugin.assert_awaited_once()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_all_installation_cannot_migrate_edits_made_while_installing(env):
|
||||
async with env.engine.begin() as conn:
|
||||
await conn.execute(sa.delete(PluginSetting))
|
||||
|
||||
async def install(*args, **kwargs):
|
||||
async with env.engine.begin() as conn:
|
||||
await conn.execute(
|
||||
sa.insert(PluginSetting).values(
|
||||
workspace_uuid=WS, plugin_author='langbot-team', plugin_name='TestAgent', enabled=True
|
||||
)
|
||||
)
|
||||
await conn.execute(sa.update(LegacyPipeline).where(LegacyPipeline.uuid == 'one').values(name='edited'))
|
||||
|
||||
env.ap.plugin_connector.install_plugin = AsyncMock(side_effect=install)
|
||||
_, metadata = await execute_all(env)
|
||||
assert metadata['results'][0]['code'] == 'preview_stale'
|
||||
assert metadata['results'][1]['state'] == 'migrated'
|
||||
configs, backups = await rows(env)
|
||||
assert configs['one'] == SOURCE
|
||||
assert len(backups) == 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_all_mode_has_no_fifty_pipeline_limit(env):
|
||||
async with env.engine.begin() as conn:
|
||||
await conn.execute(
|
||||
sa.insert(LegacyPipeline),
|
||||
[
|
||||
dict(
|
||||
uuid=f'extra-{i}',
|
||||
workspace_uuid=WS,
|
||||
name=f'extra-{i}',
|
||||
description='bulk fixture',
|
||||
for_version='4.10',
|
||||
stages=[],
|
||||
config=SOURCE,
|
||||
extensions_preferences={'enable_all_plugins': True},
|
||||
)
|
||||
for i in range(51)
|
||||
],
|
||||
)
|
||||
response, metadata = await execute_all(env, install_plugins=False)
|
||||
assert len(response['pipeline_uuids']) == 53
|
||||
assert all(r['state'] == 'migrated' for r in metadata['results'])
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_all_mode_rejects_duplicate_tasks_and_unconfirmed_requests(env):
|
||||
for body in [
|
||||
{'all': True, 'confirmed': False, 'install_plugins': True},
|
||||
{'all': True, 'confirmed': True, 'install_plugins': 'false'},
|
||||
{'all': True, 'confirmed': True, 'install_plugins': False, 'workspace_uuid': OTHER},
|
||||
]:
|
||||
with pytest.raises(env.m.MigrationError):
|
||||
await env.svc.execute(context(), body)
|
||||
env.svc._all_tasks.add(WS)
|
||||
with pytest.raises(env.m.MigrationError, match='migration_running'):
|
||||
await execute_all(env)
|
||||
env.svc._all_tasks.clear()
|
||||
|
||||
@@ -41,6 +41,7 @@ from langbot_plugin.box.security import (
|
||||
from langbot_plugin.entities.io.context import ActionContext
|
||||
from langbot.pkg.api.http.context import ExecutionContext
|
||||
from langbot.pkg.box.service import BoxService
|
||||
from langbot.pkg.box.runner import RunBoxBinding
|
||||
|
||||
_UTC = dt.timezone.utc
|
||||
_CONTEXT = ExecutionContext(
|
||||
@@ -173,7 +174,7 @@ class FakeBackend(BaseSandboxBackend):
|
||||
|
||||
|
||||
def make_query(query_id: int = 42) -> pipeline_query.Query:
|
||||
return pipeline_query.Query.model_construct(
|
||||
query = pipeline_query.Query.model_construct(
|
||||
query_id=query_id,
|
||||
query_uuid=f'query-{query_id}',
|
||||
instance_uuid=_CONTEXT.instance_uuid,
|
||||
@@ -192,6 +193,11 @@ def make_query(query_id: int = 42) -> pipeline_query.Query:
|
||||
},
|
||||
)
|
||||
|
||||
object.__setattr__(
|
||||
query, '_box_binding', RunBoxBinding(f'run-{query_id}', 'person_test_user', {}, f'query-{query_id}')
|
||||
)
|
||||
return query
|
||||
|
||||
|
||||
def make_app(
|
||||
logger: Mock,
|
||||
@@ -637,129 +643,6 @@ async def test_box_service_defaults_session_id_from_query():
|
||||
assert backend.start_calls == ['person_test_user']
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_box_service_session_id_uses_query_attributes_without_variables():
|
||||
logger = Mock()
|
||||
backend = FakeBackend(logger)
|
||||
runtime = BoxRuntime(logger=logger, backends=[backend], session_ttl_sec=300)
|
||||
service = BoxService(make_app(logger), client=_InProcessBoxRuntimeClient(logger, runtime))
|
||||
await service.initialize()
|
||||
|
||||
query = pipeline_query.Query.model_construct(
|
||||
query_id=7,
|
||||
instance_uuid=_CONTEXT.instance_uuid,
|
||||
workspace_uuid=_CONTEXT.workspace_uuid,
|
||||
placement_generation=_CONTEXT.placement_generation,
|
||||
launcher_type='group',
|
||||
launcher_id='room-1',
|
||||
)
|
||||
result = await service.execute_tool({'command': 'pwd'}, query)
|
||||
|
||||
assert result['session_id'] == 'group_room-1'
|
||||
assert result['ok'] is True
|
||||
assert backend.start_calls == ['group_room-1']
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_box_service_session_id_falls_back_to_query_id_for_synthetic_queries():
|
||||
logger = Mock()
|
||||
backend = FakeBackend(logger)
|
||||
runtime = BoxRuntime(logger=logger, backends=[backend], session_ttl_sec=300)
|
||||
service = BoxService(make_app(logger), client=_InProcessBoxRuntimeClient(logger, runtime))
|
||||
await service.initialize()
|
||||
|
||||
query = pipeline_query.Query.model_construct(
|
||||
query_id=7,
|
||||
instance_uuid=_CONTEXT.instance_uuid,
|
||||
workspace_uuid=_CONTEXT.workspace_uuid,
|
||||
placement_generation=_CONTEXT.placement_generation,
|
||||
)
|
||||
result = await service.execute_tool({'command': 'pwd'}, query)
|
||||
|
||||
assert result['session_id'] == 'query_7'
|
||||
assert result['ok'] is True
|
||||
assert backend.start_calls == ['query_7']
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_box_service_forced_global_scope_overrides_pipeline_template():
|
||||
"""SaaS guard: a non-empty ``force_box_session_id_template`` pins every
|
||||
query to one shared sandbox regardless of the pipeline's own scope."""
|
||||
logger = Mock()
|
||||
backend = FakeBackend(logger)
|
||||
runtime = BoxRuntime(logger=logger, backends=[backend], session_ttl_sec=300)
|
||||
service = BoxService(
|
||||
make_app(logger, force_box_session_id_template='{global}'),
|
||||
client=_InProcessBoxRuntimeClient(logger, runtime),
|
||||
)
|
||||
await service.initialize()
|
||||
|
||||
# Two distinct callers that would otherwise get separate sandboxes.
|
||||
q1 = pipeline_query.Query.model_construct(
|
||||
query_id=1,
|
||||
instance_uuid=_CONTEXT.instance_uuid,
|
||||
workspace_uuid=_CONTEXT.workspace_uuid,
|
||||
placement_generation=_CONTEXT.placement_generation,
|
||||
launcher_type='group',
|
||||
launcher_id='room-1',
|
||||
)
|
||||
q2 = pipeline_query.Query.model_construct(
|
||||
query_id=2,
|
||||
instance_uuid=_CONTEXT.instance_uuid,
|
||||
workspace_uuid=_CONTEXT.workspace_uuid,
|
||||
placement_generation=_CONTEXT.placement_generation,
|
||||
launcher_type='person',
|
||||
launcher_id='alice',
|
||||
)
|
||||
|
||||
r1 = await service.execute_tool({'command': 'pwd'}, q1)
|
||||
r2 = await service.execute_tool({'command': 'pwd'}, q2)
|
||||
|
||||
assert r1['session_id'] == 'global'
|
||||
assert r2['session_id'] == 'global'
|
||||
# Only one sandbox was ever started — the shared global one.
|
||||
assert backend.start_calls == ['global']
|
||||
|
||||
|
||||
def test_box_service_forced_template_ignores_pipeline_config():
|
||||
"""The forced template wins even when the pipeline explicitly sets a
|
||||
per-user scope — proving the override is not bypassable via pipeline config."""
|
||||
logger = Mock()
|
||||
service = BoxService(
|
||||
make_app(logger, force_box_session_id_template='{global}'),
|
||||
client=Mock(spec=BoxRuntimeClient),
|
||||
)
|
||||
query = pipeline_query.Query.model_construct(
|
||||
query_id=7,
|
||||
launcher_type='person',
|
||||
launcher_id='test_user',
|
||||
sender_id='test_user',
|
||||
pipeline_config={
|
||||
'ai': {'local-agent': {'box-session-id-template': '{launcher_type}_{launcher_id}_{sender_id}'}}
|
||||
},
|
||||
)
|
||||
|
||||
assert service.resolve_box_session_id(query) == 'global'
|
||||
|
||||
|
||||
def test_box_service_empty_forced_template_respects_pipeline_config():
|
||||
"""An empty/whitespace forced template is a no-op: the pipeline's own
|
||||
scope template is honoured (default non-SaaS behaviour)."""
|
||||
logger = Mock()
|
||||
service = BoxService(
|
||||
make_app(logger, force_box_session_id_template=' '),
|
||||
client=Mock(spec=BoxRuntimeClient),
|
||||
)
|
||||
query = pipeline_query.Query.model_construct(
|
||||
query_id=7,
|
||||
launcher_type='group',
|
||||
launcher_id='room-1',
|
||||
pipeline_config={'ai': {'local-agent': {'box-session-id-template': '{launcher_type}_{launcher_id}'}}},
|
||||
)
|
||||
|
||||
assert service.resolve_box_session_id(query) == 'group_room-1'
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_box_service_fails_closed_when_backend_unavailable():
|
||||
logger = Mock()
|
||||
@@ -793,7 +676,7 @@ async def test_box_service_allows_host_mount_under_configured_root(tmp_path):
|
||||
)
|
||||
|
||||
assert result['ok'] is True
|
||||
assert backend.start_calls == ['11']
|
||||
assert backend.start_calls == ['person_test_user']
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -875,41 +758,6 @@ async def test_box_service_rejects_host_mount_outside_allowed_roots(tmp_path):
|
||||
)
|
||||
|
||||
|
||||
class TestGetSystemGuidance:
|
||||
"""``get_system_guidance`` must ALWAYS advertise the per-query outbox path
|
||||
when given a ``query_id`` — even with no inbound attachment — so files the
|
||||
agent generates (QR codes, charts, rendered docs) are actually delivered.
|
||||
|
||||
The wrapper collects the outbox on every turn regardless of inbound files;
|
||||
before this, the agent was only told the outbox path inside the
|
||||
inbound-attachment note, so pure-generation turns produced files that were
|
||||
silently dropped.
|
||||
"""
|
||||
|
||||
def _service(self, logger=None):
|
||||
logger = logger or Mock()
|
||||
runtime = BoxRuntime(logger=logger, backends=[FakeBackend(logger)], session_ttl_sec=300)
|
||||
return BoxService(make_app(logger), client=_InProcessBoxRuntimeClient(logger, runtime))
|
||||
|
||||
def test_guidance_includes_outbox_when_query_id_given(self):
|
||||
service = self._service()
|
||||
guidance = service.get_system_guidance(42)
|
||||
assert f'{service.OUTBOX_MOUNT_DIR}/42' in guidance
|
||||
assert 'delivered to the user automatically' in guidance
|
||||
|
||||
def test_guidance_omits_outbox_without_query_id(self):
|
||||
service = self._service()
|
||||
guidance = service.get_system_guidance()
|
||||
assert service.OUTBOX_MOUNT_DIR not in guidance
|
||||
# core exec guidance is still present
|
||||
assert 'exec tool' in guidance
|
||||
|
||||
def test_guidance_outbox_independent_of_inbound_attachments(self):
|
||||
# A bare query_id (the pure-generation case) still gets the outbox note.
|
||||
service = self._service()
|
||||
assert f'{service.OUTBOX_MOUNT_DIR}/0' in service.get_system_guidance(0)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_box_runtime_rejects_host_mount_conflict_in_same_session(tmp_path):
|
||||
logger = Mock()
|
||||
@@ -2427,14 +2275,16 @@ class TestAttachmentHostPath:
|
||||
service, _ws = self._service_with_workspace(tmp_path)
|
||||
first = make_query(query_id=7)
|
||||
second = make_query(query_id=7)
|
||||
object.__setattr__(first, 'query_uuid', 'replica-a-query')
|
||||
object.__setattr__(second, 'query_uuid', 'replica-b-query')
|
||||
first._box_binding.io_scope = 'replica-a-run'
|
||||
object.__setattr__(first, 'query_uuid', 'same-query')
|
||||
second._box_binding.io_scope = 'replica-b-run'
|
||||
object.__setattr__(second, 'query_uuid', 'same-query')
|
||||
|
||||
first_path = service._host_query_dir(service.OUTBOX_SUBDIR, first)
|
||||
second_path = service._host_query_dir(service.OUTBOX_SUBDIR, second)
|
||||
|
||||
assert first_path is not None and first_path.endswith('/outbox/replica-a-query')
|
||||
assert second_path is not None and second_path.endswith('/outbox/replica-b-query')
|
||||
assert first_path is not None and first_path.endswith('/outbox/replica-a-run')
|
||||
assert second_path is not None and second_path.endswith('/outbox/replica-b-run')
|
||||
assert first_path != second_path
|
||||
|
||||
@pytest.mark.asyncio
|
||||
|
||||
@@ -0,0 +1,182 @@
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import AsyncMock
|
||||
import asyncio
|
||||
import base64
|
||||
|
||||
import pytest
|
||||
from langbot_plugin.api.entities.builtin.platform.message import File, MessageChain
|
||||
from langbot_plugin.box.errors import BoxValidationError
|
||||
from langbot.pkg.box.runner import RunnerBoxService, RunBoxBinding, exported_message
|
||||
|
||||
|
||||
def service():
|
||||
sessions = {}
|
||||
|
||||
async def create(context, spec):
|
||||
sessions.setdefault(spec['session_id'], spec)
|
||||
return sessions[spec['session_id']]
|
||||
|
||||
box = SimpleNamespace(
|
||||
enabled=True,
|
||||
managed_admission_required=False,
|
||||
_ATTACHMENT_MAX_TOTAL_BYTES=1000000,
|
||||
create_session=AsyncMock(side_effect=create),
|
||||
require_workspace_sandbox=AsyncMock(side_effect=lambda context: context),
|
||||
_action_context=lambda context: context,
|
||||
client=SimpleNamespace(get_sessions=AsyncMock(side_effect=lambda **kw: list(sessions.values()))),
|
||||
build_skill_extra_mounts=lambda query: [],
|
||||
)
|
||||
return RunnerBoxService(box)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_acquire_reuses_key_and_bind_is_explicit_and_run_scoped():
|
||||
api = service()
|
||||
first, second = await asyncio.gather(
|
||||
api.acquire('workspace', {'reuse_key': 'global'}),
|
||||
api.acquire('workspace', {'reuse_key': 'global'}),
|
||||
)
|
||||
assert first == second
|
||||
q1, q2 = SimpleNamespace(), SimpleNamespace()
|
||||
a = await api.bind('workspace', q1, 'run-a', first['id'])
|
||||
b = await api.bind('workspace', q2, 'run-b', first['id'])
|
||||
assert a['box_id'] == b['box_id']
|
||||
assert a['outbox'] != b['outbox']
|
||||
different = await api.acquire('workspace', {'reuse_key': 'other'})
|
||||
with pytest.raises(BoxValidationError, match='cannot change'):
|
||||
await api.bind('workspace', q1, 'run-a', different['id'])
|
||||
with pytest.raises(BoxValidationError, match='not found'):
|
||||
await api.bind('workspace', SimpleNamespace(), 'run-c', 'foreign-box')
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_concurrent_binding_cannot_switch_boxes():
|
||||
api = service()
|
||||
a = await api.acquire('ws', {'reuse_key': 'a'})
|
||||
b = await api.acquire('ws', {'reuse_key': 'b'})
|
||||
q = SimpleNamespace()
|
||||
results = await asyncio.gather(
|
||||
api.bind('ws', q, 'run', a['id']), api.bind('ws', q, 'run', b['id']), return_exceptions=True
|
||||
)
|
||||
assert sum(isinstance(r, BoxValidationError) for r in results) == 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_plugin_cannot_supply_mount_or_limit_overrides():
|
||||
api = service()
|
||||
for field in ('host_path', 'extra_mounts', 'max_sessions', 'memory_mb', 'session_id'):
|
||||
with pytest.raises(BoxValidationError):
|
||||
await api.acquire('ws', {'reuse_key': 'global', 'options': {field: '/etc'}})
|
||||
api.box.create_session.assert_not_called()
|
||||
api.box.managed_admission_required = True
|
||||
with pytest.raises(BoxValidationError, match='global'):
|
||||
await api.acquire('ws', {'reuse_key': 'other'})
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_selective_import_is_idempotent_and_separates_duplicate_names():
|
||||
api = service()
|
||||
q = SimpleNamespace(
|
||||
message_chain=MessageChain(
|
||||
[
|
||||
File(name='same.txt', base64=base64.b64encode(b'a').decode()),
|
||||
File(name='same.txt', base64=base64.b64encode(b'b').decode()),
|
||||
]
|
||||
),
|
||||
_box_binding=RunBoxBinding('run', 'box', {}, 'run'),
|
||||
)
|
||||
|
||||
async def materialize(query):
|
||||
return [
|
||||
{
|
||||
'name': 'same.txt',
|
||||
'type': 'File',
|
||||
'size': 1,
|
||||
'path': f'/workspace/inbox/{query._box_binding.io_scope}/same.txt',
|
||||
}
|
||||
]
|
||||
|
||||
api.box.materialize_inbound_attachments = AsyncMock(side_effect=materialize)
|
||||
a = await api.import_attachments(q, ['attachment-1'])
|
||||
b = await api.import_attachments(q, None)
|
||||
assert a['items'][0] == b['items'][1]
|
||||
assert b['items'][0]['path'] != b['items'][1]['path']
|
||||
assert api.box.materialize_inbound_attachments.await_count == 2
|
||||
with pytest.raises(BoxValidationError, match='Unknown'):
|
||||
await api.import_attachments(q, ['foreign-file'])
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_export_does_not_send_and_references_cannot_cross_runs_or_repeat():
|
||||
api = service()
|
||||
q = SimpleNamespace(_box_binding=RunBoxBinding('run', 'box', {}, 'run'))
|
||||
api.box.collect_outbound_attachments = AsyncMock(
|
||||
return_value=[
|
||||
{'name': 'answer.txt', 'type': 'File', 'base64': base64.b64encode(b'answer').decode()},
|
||||
]
|
||||
)
|
||||
result = await api.export_files(q)
|
||||
file = result['items'][0]
|
||||
assert 'base64' not in file and file['size'] == 6
|
||||
other = SimpleNamespace(_box_binding=RunBoxBinding('other-run', 'box', {}, 'other-run'))
|
||||
with pytest.raises(BoxValidationError):
|
||||
exported_message(other, [file['id']])
|
||||
chain = exported_message(q, [file['id']], consume=True)
|
||||
assert chain[0].name == 'answer.txt'
|
||||
with pytest.raises(BoxValidationError, match='already'):
|
||||
exported_message(q, [file['id']])
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_status_preserves_unknown_capacity_and_connection_failure():
|
||||
api = service()
|
||||
api.box.get_status = AsyncMock(return_value={'available': False, 'connector_error': 'offline'})
|
||||
result = await api.status('ws')
|
||||
assert result['remaining'] is None and result['limit'] is None
|
||||
assert result['available'] is False
|
||||
|
||||
|
||||
def test_event_attachments_preserved_without_eager_io_or_host_path_access():
|
||||
from langbot_plugin.api.entities.builtin.runner.input import AgentInput, InputAttachment
|
||||
from langbot.pkg.box.runner import prepare_input_files, input_files
|
||||
from langbot_plugin.api.entities.builtin.provider.message import ContentElement
|
||||
|
||||
q = SimpleNamespace(message_chain=MessageChain([]))
|
||||
value = AgentInput(attachments=[InputAttachment(type='file', name='a.txt', content='YQ==', path='/etc/passwd')])
|
||||
prepare_input_files(q, value)
|
||||
assert value.attachments[0].content == 'YQ=='
|
||||
assert value.attachments[0].ref == 'attachment-0'
|
||||
assert value.attachments[0].path is None
|
||||
assert input_files(q)[0].base64 == 'YQ=='
|
||||
assert not input_files(q)[0].path
|
||||
image = AgentInput(contents=[ContentElement.from_image_url('https://example.invalid/image.png')])
|
||||
prepare_input_files(SimpleNamespace(message_chain=MessageChain([])), image)
|
||||
assert image.attachments[0].url == 'https://example.invalid/image.png'
|
||||
|
||||
|
||||
def test_attachment_references_follow_metadata_not_platform_list_order():
|
||||
from langbot_plugin.api.entities.builtin.runner.input import AgentInput, InputAttachment
|
||||
from langbot.pkg.box.runner import prepare_input_files, input_files
|
||||
from langbot_plugin.api.entities.builtin.platform.message import Image
|
||||
|
||||
a, b = File(name='a', base64='YQ=='), Image(url='https://example.invalid/b')
|
||||
q = SimpleNamespace(message_chain=MessageChain([a, b]))
|
||||
value = AgentInput(
|
||||
attachments=[InputAttachment(type='image', url=b.url), InputAttachment(type='file', name='a', content='YQ==')]
|
||||
)
|
||||
prepare_input_files(q, value)
|
||||
assert input_files(q) == [b, a]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_image_export_accepts_data_url_and_enforces_total_bytes():
|
||||
api = service()
|
||||
q = SimpleNamespace(_box_binding=RunBoxBinding('run', 'box', {}, 'run'))
|
||||
api.box.collect_outbound_attachments = AsyncMock(
|
||||
return_value=[{'name': 'a.png', 'type': 'Image', 'base64': 'data:image/png;base64,YQ=='}]
|
||||
)
|
||||
result = await api.export_files(q)
|
||||
assert result['items'][0]['size'] == 1
|
||||
api.box._ATTACHMENT_MAX_TOTAL_BYTES = 1
|
||||
with pytest.raises(BoxValidationError, match='byte limit'):
|
||||
await api.export_files(q)
|
||||
@@ -11,7 +11,7 @@ import pytest
|
||||
|
||||
FIXTURES = json.loads((Path(__file__).parents[2] / 'fixtures/pipeline_migration/synthetic_legacy.json').read_text())
|
||||
TARGETS = {
|
||||
'local-agent': ('LocalAgent', '0.1.6', None),
|
||||
'local-agent': ('LocalAgent', '0.1.7', None),
|
||||
'dify-service-api': ('DifyAgent', '0.1.7', None),
|
||||
'coze-api': ('CozeAgent', '0.1.7', None),
|
||||
'dashscope-app-api': ('DashScopeAgent', '0.1.7', None),
|
||||
@@ -411,7 +411,6 @@ def test_local_rounds_are_never_translated_into_transcript_item_counts(rounds):
|
||||
('prompt', [{'role': 'system', 'content': 42}], 'local.prompt_shape'),
|
||||
('prompt', [{'role': 'system', 'content': 'text', 'SECRET-key': 'SECRET-value'}], 'local.prompt_shape'),
|
||||
('prompt', [{'role': 'system', 'content': [{'type': 'text', 'text': 42}]}], 'local.prompt_shape'),
|
||||
('box-session-id-template', '{global}', 'local.box_scope'),
|
||||
],
|
||||
)
|
||||
def test_local_unsupported_behaviors_have_specific_safe_blockers(field, value, code):
|
||||
@@ -980,7 +979,16 @@ def test_default_local_agent_blocks_instead_of_inventing_round_translation():
|
||||
assert planner().PLANNER_VERSION == '3'
|
||||
assert result['state'] == 'blocked'
|
||||
assert result['target_runner_id'] == 'plugin:langbot-team/LocalAgent/default'
|
||||
assert result['target_plugin'] == {'author': 'langbot-team', 'name': 'LocalAgent', 'version': '0.1.6'}
|
||||
assert result['target_plugin'] == {'author': 'langbot-team', 'name': 'LocalAgent', 'version': '0.1.7'}
|
||||
assert {'code': 'missing_field', 'field': 'ai.local-agent.prompt'} in result['blockers']
|
||||
assert result['config'] is None
|
||||
assert source == original
|
||||
|
||||
|
||||
@pytest.mark.parametrize('template', ['{global}', '{launcher_type}_{launcher_id}', '{sender_id}', '{project}'])
|
||||
def test_local_box_reuse_templates_are_preserved_for_plugin(template):
|
||||
source = source_for('local-agent')
|
||||
source['ai']['local-agent']['box-session-id-template'] = template
|
||||
result = plan(source)
|
||||
assert result['state'] != 'blocked', result
|
||||
assert result['config']['ai']['runner_config'][result['target_runner_id']]['box-session-id-template'] == template
|
||||
|
||||
@@ -48,20 +48,18 @@ def test_sdk_valid_omitted_prompt_content_is_preserved_exactly():
|
||||
assert 'content' not in migrated[0]
|
||||
|
||||
|
||||
@pytest.mark.parametrize('template', ['', '{launcher_type}_{launcher_id}'])
|
||||
def test_standard_box_template_is_explicit_reset_not_custom_scope_block(template):
|
||||
@pytest.mark.parametrize('template', ['{global}', '{launcher_type}_{launcher_id}', '{launcher_id}', '{workspace}'])
|
||||
def test_box_reuse_template_is_preserved_for_runner(template):
|
||||
source = {'ai': {'runner': {'runner': 'local-agent'}, 'local-agent': copy.deepcopy(FIXTURES['local-agent'])}}
|
||||
source['ai']['local-agent']['box-session-id-template'] = template
|
||||
result = plan_legacy_pipeline(source)
|
||||
assert result['state'] == 'ready', result['blockers']
|
||||
assert {'code': 'local.box_state_reset', 'field': 'ai.local-agent.box-session-id-template'} in result['warnings']
|
||||
assert 'box-session-id-template' not in result['config']['ai']['runner_config'][result['target_runner_id']]
|
||||
assert result['config']['ai']['runner_config'][result['target_runner_id']]['box-session-id-template'] == template
|
||||
|
||||
|
||||
@pytest.mark.parametrize('template', ['global', '{launcher_id}', '{workspace}', ' {launcher_type}_{launcher_id}'])
|
||||
def test_custom_box_sharing_stays_blocked(template):
|
||||
def test_empty_box_template_requires_correction():
|
||||
source = {'ai': {'runner': {'runner': 'local-agent'}, 'local-agent': copy.deepcopy(FIXTURES['local-agent'])}}
|
||||
source['ai']['local-agent']['box-session-id-template'] = template
|
||||
source['ai']['local-agent']['box-session-id-template'] = ''
|
||||
result = plan_legacy_pipeline(source)
|
||||
assert result['state'] == 'blocked'
|
||||
assert {'code': 'local.box_scope', 'field': 'ai.local-agent.box-session-id-template'} in result['blockers']
|
||||
assert {'code': 'invalid_type', 'field': 'ai.local-agent.box-session-id-template'} in result['blockers']
|
||||
|
||||
@@ -340,6 +340,7 @@ class TestResponseWrapperAssistant:
|
||||
assistant_resp = Mock()
|
||||
assistant_resp.role = 'assistant'
|
||||
assistant_resp.content = None
|
||||
assistant_resp.attachments = None
|
||||
assistant_resp.tool_calls = None
|
||||
query.resp_messages = [assistant_resp]
|
||||
|
||||
|
||||
@@ -1,146 +1,18 @@
|
||||
"""Unit tests for ResponseWrapper outbound-attachment helpers.
|
||||
|
||||
Covers the sandbox -> user attachment path added for the Box attachment
|
||||
round-trip:
|
||||
|
||||
* ``_is_final_assistant_message`` — only the terminal, tool-call-free assistant
|
||||
message (or a final MessageChunk) should trigger collection.
|
||||
* ``_append_outbound_attachments`` — collects sandbox outbox files exactly once
|
||||
per query and maps each descriptor to the right platform component, swallowing
|
||||
collection errors.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import AsyncMock, Mock
|
||||
"""Output attachments are explicitly submitted, never scanned by the wrapper."""
|
||||
|
||||
from langbot_plugin.api.entities.builtin.platform.message import MessageChain, File
|
||||
from langbot_plugin.api.entities.builtin.provider.message import Message, MessageChunk
|
||||
import pytest
|
||||
|
||||
import langbot_plugin.api.entities.builtin.platform.message as platform_message
|
||||
import langbot_plugin.api.entities.builtin.provider.message as provider_message
|
||||
|
||||
from langbot.pkg.pipeline.wrapper.wrapper import ResponseWrapper
|
||||
@pytest.mark.parametrize('cls', [Message, MessageChunk])
|
||||
def test_explicit_output_attachments_survive_platform_conversion_only(cls):
|
||||
message = cls(role='assistant', content='done', attachments=MessageChain([File(name='result.txt', base64='YQ==')]))
|
||||
assert message.get_content_platform_message_chain()[-1].name == 'result.txt'
|
||||
assert 'attachments' not in message.model_dump()
|
||||
|
||||
|
||||
def _make_wrapper(box_service) -> ResponseWrapper:
|
||||
app = SimpleNamespace(logger=Mock())
|
||||
wrapper = ResponseWrapper.__new__(ResponseWrapper)
|
||||
wrapper.ap = app
|
||||
return wrapper
|
||||
|
||||
|
||||
def _make_query():
|
||||
return SimpleNamespace(variables={})
|
||||
|
||||
|
||||
def test_is_final_assistant_message_plain_assistant():
|
||||
wrapper = _make_wrapper(box_service=None)
|
||||
msg = provider_message.Message(role='assistant', content='done')
|
||||
assert wrapper._is_final_assistant_message(msg) is True
|
||||
|
||||
|
||||
def test_is_final_assistant_message_rejects_non_assistant():
|
||||
wrapper = _make_wrapper(box_service=None)
|
||||
msg = provider_message.Message(role='tool', content='{}')
|
||||
assert wrapper._is_final_assistant_message(msg) is False
|
||||
|
||||
|
||||
def test_is_final_assistant_message_rejects_tool_call_round():
|
||||
wrapper = _make_wrapper(box_service=None)
|
||||
msg = provider_message.Message(
|
||||
role='assistant',
|
||||
content='calling',
|
||||
tool_calls=[
|
||||
provider_message.ToolCall(
|
||||
id='c1',
|
||||
type='function',
|
||||
function=provider_message.FunctionCall(name='exec', arguments='{}'),
|
||||
)
|
||||
],
|
||||
)
|
||||
assert wrapper._is_final_assistant_message(msg) is False
|
||||
|
||||
|
||||
def test_is_final_assistant_message_non_final_chunk():
|
||||
wrapper = _make_wrapper(box_service=None)
|
||||
chunk = provider_message.MessageChunk(role='assistant', content='partial', is_final=False)
|
||||
assert wrapper._is_final_assistant_message(chunk) is False
|
||||
|
||||
final_chunk = provider_message.MessageChunk(role='assistant', content='partial', is_final=True)
|
||||
assert wrapper._is_final_assistant_message(final_chunk) is True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_append_outbound_attachments_maps_each_type():
|
||||
box_service = SimpleNamespace(
|
||||
available=True,
|
||||
collect_outbound_attachments=AsyncMock(
|
||||
return_value=[
|
||||
{'type': 'Image', 'base64': 'data:image/png;base64,iVBORw0K'},
|
||||
{'type': 'Voice', 'base64': 'data:audio/wav;base64,UklGRg=='},
|
||||
{'type': 'File', 'name': 'report.xlsx', 'base64': 'data:app;base64,UEsDBA=='},
|
||||
]
|
||||
),
|
||||
)
|
||||
wrapper = _make_wrapper(box_service)
|
||||
wrapper.ap.box_service = box_service
|
||||
query = _make_query()
|
||||
chain = platform_message.MessageChain([])
|
||||
|
||||
await wrapper._append_outbound_attachments(query, chain)
|
||||
|
||||
kinds = [type(c).__name__ for c in chain]
|
||||
assert kinds == ['Image', 'Voice', 'File']
|
||||
assert query.variables['_sandbox_outbound_collected'] is True
|
||||
# File keeps its name
|
||||
file_comp = chain[2]
|
||||
assert getattr(file_comp, 'name', None) == 'report.xlsx'
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_append_outbound_attachments_runs_once_per_query():
|
||||
box_service = SimpleNamespace(
|
||||
available=True,
|
||||
collect_outbound_attachments=AsyncMock(return_value=[]),
|
||||
)
|
||||
wrapper = _make_wrapper(box_service)
|
||||
wrapper.ap.box_service = box_service
|
||||
query = _make_query()
|
||||
query.variables['_sandbox_outbound_collected'] = True
|
||||
chain = platform_message.MessageChain([])
|
||||
|
||||
await wrapper._append_outbound_attachments(query, chain)
|
||||
|
||||
box_service.collect_outbound_attachments.assert_not_awaited()
|
||||
assert len(chain) == 0
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_append_outbound_attachments_noop_without_box_service():
|
||||
wrapper = _make_wrapper(box_service=None)
|
||||
wrapper.ap.box_service = None
|
||||
query = _make_query()
|
||||
chain = platform_message.MessageChain([])
|
||||
|
||||
await wrapper._append_outbound_attachments(query, chain)
|
||||
assert len(chain) == 0
|
||||
# not marked collected, since service is unavailable
|
||||
assert '_sandbox_outbound_collected' not in query.variables
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_append_outbound_attachments_swallows_collection_error():
|
||||
box_service = SimpleNamespace(
|
||||
available=True,
|
||||
collect_outbound_attachments=AsyncMock(side_effect=RuntimeError('boom')),
|
||||
)
|
||||
wrapper = _make_wrapper(box_service)
|
||||
wrapper.ap.box_service = box_service
|
||||
query = _make_query()
|
||||
chain = platform_message.MessageChain([])
|
||||
|
||||
# must not raise
|
||||
await wrapper._append_outbound_attachments(query, chain)
|
||||
assert len(chain) == 0
|
||||
wrapper.ap.logger.warning.assert_called_once()
|
||||
@pytest.mark.parametrize('cls', [Message, MessageChunk])
|
||||
def test_file_only_output(cls):
|
||||
message = cls(role='assistant', attachments=MessageChain([File(name='result.txt', base64='YQ==')]))
|
||||
assert len(message.get_content_platform_message_chain()) == 1
|
||||
|
||||
@@ -740,3 +740,25 @@ async def test_missing_artifact_repair_adds_dependency_failure_and_continues():
|
||||
]
|
||||
assert setting_a.installation_uuid in connector._installation_failures
|
||||
assert setting_b.installation_uuid not in connector._installation_failures
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_marketplace_download_honors_migration_version_without_latest_lookup(monkeypatch):
|
||||
import langbot.pkg.plugin.connector as connector_module
|
||||
|
||||
connector = connection_result_connector(AsyncMock())
|
||||
download = AsyncMock(return_value=(200, b'pinned-plugin-package'))
|
||||
monkeypatch.setattr(connector_module, '_marketplace_get', download)
|
||||
package, version = await connector._download_marketplace_package(
|
||||
SimpleNamespace(), 'langbot-team', 'LocalAgent', None, version='0.1.6'
|
||||
)
|
||||
assert package == b'pinned-plugin-package'
|
||||
assert version == '0.1.6'
|
||||
download.assert_awaited_once()
|
||||
assert download.call_args.args[1].endswith('/plugins/download/langbot-team/LocalAgent/0.1.6')
|
||||
for version in ('../latest', '1.0?token=x', '1.0/other'):
|
||||
with pytest.raises(ValueError, match='Invalid plugin version'):
|
||||
await connector._download_marketplace_package(
|
||||
SimpleNamespace(), 'langbot-team', 'LocalAgent', None, version=version
|
||||
)
|
||||
assert download.await_count == 1
|
||||
|
||||
@@ -1597,6 +1597,9 @@ class TestAgentRunProxyActions:
|
||||
app.tool_mgr = tool_mgr
|
||||
|
||||
run_id = 'run_pure_event_native_exec'
|
||||
from langbot.pkg.box.runner import RunBoxBinding
|
||||
|
||||
object.__setattr__(query, '_box_binding', RunBoxBinding(run_id, 'box', {}, run_id))
|
||||
registry = get_session_registry()
|
||||
await registry.unregister(run_id)
|
||||
await registry.register(
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
from __future__ import annotations
|
||||
from langbot.pkg.box.runner import RunBoxBinding
|
||||
|
||||
import base64
|
||||
import contextlib
|
||||
@@ -236,6 +237,7 @@ async def test_native_tool_loader_rechecks_admission_at_the_final_invoke_boundar
|
||||
query_uuid=None,
|
||||
)
|
||||
|
||||
query._box_binding = RunBoxBinding('run', 'box', {}, 'run')
|
||||
with pytest.raises(RuntimeError, match='entitlement expired'):
|
||||
await loader.invoke_tool('read', {'path': '/workspace/private.txt'}, query)
|
||||
|
||||
@@ -260,6 +262,7 @@ def _make_loader_with_workspace(tmpdir: str) -> tuple[NativeToolLoader, Mock]:
|
||||
|
||||
def _make_query() -> SimpleNamespace:
|
||||
return SimpleNamespace(
|
||||
_box_binding=RunBoxBinding('run', 'box', {}, 'run'),
|
||||
query_id='test-query-1',
|
||||
query_uuid='test-query-1',
|
||||
instance_uuid=_CONTEXT.instance_uuid,
|
||||
@@ -510,7 +513,9 @@ async def test_path_escape_blocked():
|
||||
],
|
||||
)
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.skipif(not native_loader._SECURE_HOST_FILE_OPS_AVAILABLE, reason='Requires POSIX descriptor-relative host APIs')
|
||||
@pytest.mark.skipif(
|
||||
not native_loader._SECURE_HOST_FILE_OPS_AVAILABLE, reason='Requires POSIX descriptor-relative host APIs'
|
||||
)
|
||||
async def test_host_workspace_operations_do_not_follow_a_swapped_ancestor(
|
||||
monkeypatch,
|
||||
tool_name: str,
|
||||
|
||||
Reference in New Issue
Block a user