fix(box): validate and export explicitly selected files (#2641)

* fix(box): retain outbox files when export fails

* test(box): retain ownership evidence in CI logs

* test(box): exercise LocalAgent export repair through real runtime and Docker

Co-authored-by: multica-agent <[email protected]>

* fix(box): complete bounded outbox recovery contract

* feat(box): export explicitly selected files

Co-authored-by: multica-agent <[email protected]>

* test(box): exercise explicit export on real Docker

Co-authored-by: multica-agent <[email protected]>

* test(box): verify explicit exports with live model and Docker

Co-authored-by: multica-agent <[email protected]>

* fix(box): align explicit export SDK and integration contracts

* docs(box): describe selected-file export contract

---------

Co-authored-by: HaiYangBG1 <[email protected]>
Co-authored-by: Chin Rock <[email protected]>
Co-authored-by: multica-agent <[email protected]>
This commit is contained in:
海洋饼干
2026-10-19 15:31:33 +08:00
committed by GitHub
co-authored by multica-agent HaiYangBG1 Chin Rock
parent bce94bd0d9
commit 947d8addea
20 changed files with 1513 additions and 137 deletions
+214 -13
View File
@@ -554,7 +554,14 @@ def _run_local_agent_probe(tmpdir: Path, probe):
platform_utils.standalone_runtime = previous_standalone_runtime
os.chdir(previous_cwd)
return asyncio.run(_run())
# Source installs resolve data from the checkout, not the current working
# directory. Scope the override to this probe, without leaking to other E2Es.
with pytest.MonkeyPatch.context() as patch:
patch.setenv('LANGBOT_DATA_ROOT', str(tmpdir / 'data'))
# Do not replace pytest-asyncio's policy loop when this synchronous
# probe follows async integration tests in the same pytest process.
with asyncio.Runner(loop_factory=asyncio.new_event_loop) as runner:
return runner.run(_run())
@pytest.mark.contract('agent_ledger')
@@ -1022,15 +1029,24 @@ def test_local_runner_combines_rag_compaction_and_multi_turn_tool_loop(
conn.close()
@pytest.mark.parametrize(
'scenario',
['normal', 'repair', 'streaming', 'tool_limit', 'cleanup', 'invalid', 'timeout', 'cancelled', 'no_export'],
)
@pytest.mark.contract('agent_box')
def test_local_runner_owns_box_reuse_and_explicit_files(
local_agent_e2e_tmpdir,
local_agent_e2e_config_path,
local_agent_runtime_process,
capfd,
scenario,
):
"""Real plugin RPC and Docker sandbox, with only the model scripted."""
del local_agent_e2e_config_path, local_agent_runtime_process
import base64
from unittest.mock import AsyncMock
repair_export = scenario in {'repair', 'streaming', 'tool_limit', 'cancelled'}
from langbot_plugin.box.backend import DockerBackend
from langbot_plugin.box.runtime import BoxRuntime
from langbot_plugin.api.entities.builtin.runner.input import InputAttachment
@@ -1062,6 +1078,8 @@ def test_local_runner_owns_box_reuse_and_explicit_files(
self.box = box
self.bindings = []
self.outboxes = []
self.calls_by_run = {}
self.queries = []
async def get_resolved_tool_catalog(self, *args, **kwargs):
return [{'name': 'exec', 'source': 'native', 'source_id': None}]
@@ -1074,21 +1092,26 @@ def test_local_runner_owns_box_reuse_and_explicit_files(
}
async def execute_func_call(self, name, parameters, query=None, source_ref=None):
self.queries.append(query)
binding = binding_for(query)
self.bindings.append((binding.session_id, binding.run_id))
source = next(iter(binding.imported.values()))['path']
outbox = f'/workspace/outbox/{binding.io_scope}'
self.outboxes.append(outbox)
self.calls_by_run[binding.run_id] = self.calls_by_run.get(binding.run_id, 0) + 1
# Shell arguments are Host-issued paths, never model input.
import shlex
directory_mode = '-m 700 ' if len(self.bindings) == 2 else ''
directory_mode = '-m 700 ' if len(self.calls_by_run) == 2 else ''
extra = f' && printf KEEP > {shlex.quote(outbox + "/keep.txt")}'
if repair_export and self.calls_by_run[binding.run_id] == 1:
extra += f' && printf 1234567890123456789012345 > {shlex.quote(outbox + "/answer.txt")}'
result = await self.box.execute_tool(
{
'command': (
f'mkdir {directory_mode}-p {shlex.quote(outbox)} && '
f'cp {shlex.quote(source)} {shlex.quote(outbox + "/answer.txt")} && '
f"stat -c '%u %a' {shlex.quote(outbox)} {shlex.quote(outbox + '/answer.txt')}"
f"stat -c '%u %a' {shlex.quote(outbox)} {shlex.quote(outbox + '/answer.txt')}" + extra
)
},
query,
@@ -1096,8 +1119,10 @@ def test_local_runner_owns_box_reuse_and_explicit_files(
assert result['ok'], result
ownership = result['stdout'].strip().splitlines()
assert ownership[-1] == '0 600', ownership
if len(self.bindings) == 2:
if len(self.calls_by_run) == 2:
assert ownership[0] == '0 700', ownership
with capfd.disabled():
print(f'Core uid={os.geteuid()}; LocalAgent Docker ownership: {ownership}', flush=True)
return result
async def probe(ap):
@@ -1117,13 +1142,101 @@ def test_local_runner_owns_box_reuse_and_explicit_files(
box = BoxService(ap, client=Client(ap.logger, runtime))
ap.box_service = box
await box.initialize()
if repair_export:
box._ATTACHMENT_MAX_BYTES = 8
collect = box.collect_selected_files
export_calls = []
async def observed_collect(query, paths):
export_calls.append(query._box_binding.run_id)
if scenario == 'cancelled' and export_calls.count(query._box_binding.run_id) == 2:
from langbot.pkg.agent.runner.run_ledger_store import RunLedgerStore
row = await RunLedgerStore(ap.persistence_mgr.get_db_engine()).request_cancel(
run_id=query._box_binding.run_id,
status_reason='cancel during export recovery',
)
assert row is not None
await asyncio.sleep(10)
raise RuntimeError('Cancelled export must not resume generation')
if scenario == 'invalid':
from langbot.pkg.box.export_errors import export_error
raise export_error('invalid_response')
if scenario == 'timeout':
await asyncio.sleep(6)
raise TimeoutError('Injected stalled export')
return await collect(query, paths)
box.collect_selected_files = observed_collect
if scenario == 'cleanup':
# A failed best-effort cleanup leaves successfully accepted files.
box.clear_selected_files = AsyncMock(
return_value='Files accepted, but cleanup failed; do not repeat delivery.'
)
manager = ToolManager(box)
ap.tool_mgr = manager
fake = await _inject_fake_llm_model(ap)
if scenario == 'streaming':
from langbot_plugin.api.entities.builtin.provider.message import MessageChunk
async def scripted_stream(*args, **kwargs):
message = await fake.invoke_llm(*args, **kwargs)
yield MessageChunk(
role='assistant', content=message.content, tool_calls=message.tool_calls, is_final=True
)
fake.invoke_llm_stream = scripted_stream
context = await ap.plugin_connector._current_execution_context()
async def assert_retained():
inspection = await box.execute_in_context(
context,
{
'cmd': f'test -f {shlex.quote(manager.outboxes[-1] + "/answer.txt")}',
'session_id': manager.bindings[-1][0],
},
)
assert inspection.ok, inspection
try:
for index in range(2):
fake.queue_llm_responses(_scripted_tool_call('exec'), 'File is ready.')
def export_call(**kwargs):
from langbot_plugin.api.entities.builtin.provider.message import FunctionCall, Message, ToolCall
return Message(
role='assistant',
content='',
tool_calls=[
ToolCall(
id=f'export-{len(export_calls)}',
type='function',
function=FunctionCall(
name='export_box_files',
arguments=json.dumps(
{
'paths': [manager.outboxes[-1] + '/answer.txt'],
}
),
),
)
],
)
fake.queue_llm_responses(_scripted_tool_call('exec'))
if scenario == 'no_export':
fake.queue_llm_responses('Created the file without exporting it.')
else:
fake.queue_llm_responses(export_call)
if scenario in {'repair', 'streaming', 'cancelled'}:
fake.queue_llm_responses(_scripted_tool_call('exec', call_id='repair'), export_call)
elif scenario in {'cleanup', 'tool_limit'}:
fake.queue_llm_responses(export_call)
if scenario == 'tool_limit':
fake.queue_llm_responses(export_call)
elif scenario not in {'invalid', 'timeout', 'cancelled'}:
fake.queue_llm_responses('File accepted.')
event = _event(
event_id=f'box-event-{index}',
conversation_id=f'box-conversation-{index}',
@@ -1140,20 +1253,108 @@ def test_local_runner_owns_box_reuse_and_explicit_files(
runner_config={'box-enabled': True, 'box-session-id-template': '{global}'},
)
binding.processor_type = 'pipeline'
messages = await _run_runner(ap, event, binding)
if scenario == 'streaming':
event.delivery.supports_streaming = True
binding.delivery_policy.enable_streaming = True
if scenario == 'timeout':
binding.runner_config['timeout'] = 5
if scenario == 'tool_limit':
binding.runner_config['max-tool-iterations'] = 3
events = []
async def observe(result):
events.append(result)
if scenario == 'cleanup' and result['type'] == 'run.completed':
query = manager.queries[-1]
assert len(query._box_binding.exported) == 1
assert len(query._box_binding.submitted) == 1
from langbot.pkg.agent.runner.errors import RunnerExecutionError
messages = []
failure = None
try:
async for message in ap.agent_run_orchestrator.run(
event,
binding,
adapter_context={'_execution_context': context, '_result_observer': observe},
):
messages.append(message)
except RunnerExecutionError as exc:
failure = exc
terminals = [e for e in events if e['type'] in {'run.completed', 'run.failed'}]
if scenario == 'timeout':
assert failure is not None and 'timed out' in str(failure)
assert not any(e['type'] in {'run.completed', 'message.completed'} for e in events)
assert not any(message.attachments for message in messages)
await assert_retained()
continue
assert len(terminals) == 1, events
if scenario in {'invalid', 'cancelled'}:
assert failure is not None
assert terminals[0]['type'] == 'run.failed', events
assert not any(e['type'] == 'message.completed' for e in events), events
assert not any(message.attachments for message in messages), messages
if scenario == 'cancelled':
assert terminals[0]['data']['code'] == 'cancelled'
else:
assert 'invalid attachment data' in terminals[0]['data']['error']
await assert_retained()
continue
assert failure is None, failure
assert terminals[0]['type'] == 'run.completed', events
assert sum(e['type'] == 'message.completed' for e in events) == 1
if scenario == 'streaming':
deltas = [e for e in events if e['type'] == 'message.delta']
assert deltas and all(not e['data'].get('file_ids') for e in deltas)
output = [component for message in messages for component in (message.attachments or [])]
assert len(output) == 1, messages
assert base64.b64decode(output[0].base64) == f'run-{index}'.encode()
if scenario in {'tool_limit', 'no_export'}:
assert not output, messages
await assert_retained()
if scenario == 'tool_limit':
assert any('limit' in _message_text(message).lower() for message in messages)
else:
assert len(output) == 1, messages
assert base64.b64decode(output[0].base64) == f'run-{index}'.encode()
inspection = await box.execute_in_context(
context,
{'cmd': f'ls -A {shlex.quote(manager.outboxes[-1])}', 'session_id': manager.bindings[-1][0]},
{
'cmd': f'ls -A {shlex.quote(manager.outboxes[-1])}',
'session_id': manager.bindings[-1][0],
},
)
assert inspection.exit_code == 0, inspection
assert inspection.stdout.strip() == '', inspection.stdout
assert inspection.ok, inspection
expected_files = {'keep.txt'}
if scenario in {'cleanup', 'tool_limit', 'no_export'}:
expected_files.add('answer.txt')
assert set(inspection.stdout.split()) == expected_files
status = await RunnerBoxService(box).status(context)
assert status['used'] == 1 and status['remaining'] == 0, status
assert manager.bindings[0][0] == manager.bindings[1][0]
assert manager.bindings[0][1] != manager.bindings[1][1]
assert manager.bindings[0][0] == manager.bindings[-1][0]
assert manager.bindings[0][1] != manager.bindings[-1][1]
if repair_export:
assert list(manager.calls_by_run.values()) == ([1, 1] if scenario == 'tool_limit' else [2, 2])
feedback = [
text for payload in _invoke_payload_texts(fake) for text in payload if 'LANGBOT_EXPORT_V1:' in text
]
assert feedback and all(
'"limit":8' in text.replace(' ', '') and '"actual":25' in text.replace(' ', '') for text in feedback
)
assert all(
'file_bytes' in text and 'answer.txt' in text and 'files_retained' in text for text in feedback
)
expected_exports = {
'normal': 1,
'repair': 2,
'streaming': 2,
'tool_limit': 2,
'cleanup': 2,
'invalid': 1,
'timeout': 1,
'cancelled': 2,
'no_export': 0,
}[scenario]
assert len(export_calls) == 2 * expected_exports
from langbot_plugin.box.errors import BoxCapacityExceededError
with pytest.raises(BoxCapacityExceededError):