feat: delegate sandbox policy to runners and simplify pipeline migration

This commit is contained in:
RockChinQ
2026-09-17 23:05:27 +08:00
parent 8c119bc4b6
commit b264d46d77
57 changed files with 2111 additions and 1751 deletions
@@ -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()
+14 -164
View File
@@ -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
+182
View File
@@ -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,