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
+5 -5
View File
@@ -1938,7 +1938,7 @@ class TestAttachmentHelpers:
{'name': 'report.pdf', 'b64': 'CCC'},
{'name': 'sub/dir/photo.JPG', 'b64': 'DDD'},
{'name': 'noext', 'b64': 'EEE'},
{'name': 'skip', 'b64': ''}, # dropped (no payload)
{'name': 'empty.txt', 'b64': ''}, # valid zero-byte file
]
out = BoxService._classify_outbound_entries(entries)
by_name = {a['name']: a for a in out}
@@ -1951,7 +1951,7 @@ class TestAttachmentHelpers:
# nested path collapses to basename, case-insensitive ext
assert by_name['photo.JPG']['type'] == 'Image'
assert by_name['noext']['type'] == 'File'
assert 'skip' not in by_name
assert by_name['empty.txt']['base64'] == ''
@pytest.mark.asyncio
async def test_component_to_bytes_from_data_uri(self):
@@ -2092,8 +2092,8 @@ class TestInboundOutboundRoundTrip:
calls = []
async def fake_client_execute(spec, *, action_context=None):
assert action_context.workspace_uuid == query.workspace_uuid
async def fake_client_execute(spec, *, action_context):
assert action_context == _ACTION_CONTEXT
cmd = spec.cmd
calls.append(cmd)
if 'os.scandir' in cmd:
@@ -2294,7 +2294,7 @@ class TestAttachmentHostPath:
for index in range(513):
os.symlink(harmless_target, os.path.join(outbox, f'entry-{index}'))
with pytest.raises(BoxValidationError, match='symbolic link'):
with pytest.raises(BoxValidationError, match='directory-entry limit'):
await service.collect_outbound_attachments(query)
assert harmless_target.read_bytes() == b'x'
+434
View File
@@ -0,0 +1,434 @@
"""Outbox failures must retain files; fallback reads use the bound Workspace."""
import asyncio
import base64
import errno
import json
import os
from pathlib import Path
from unittest.mock import AsyncMock, Mock
import pytest
from langbot.pkg.box import secure_fs
from langbot.pkg.box.service import BoxService
from langbot_plugin.box.client import BoxRuntimeClient
from langbot_plugin.box.errors import BoxError, BoxValidationError
from langbot_plugin.box.models import BoxExecutionResult, BoxExecutionStatus
from tests.unit_tests.box.test_box_service import make_app, make_query, _CONTEXT
@pytest.fixture
def env(tmp_path):
app = make_app(Mock(), allowed_mount_roots=[str(tmp_path)], host_root=str(tmp_path / 'box'))
box = BoxService(app, client=Mock(spec=BoxRuntimeClient))
box._available = True
box.default_workspace = str(tmp_path / 'box' / 'default')
workspace = Path(box._tenant_workspace(_CONTEXT))
query = make_query()
outbox = workspace / 'outbox' / query.query_uuid
outbox.mkdir(parents=True)
# The actual generated exec reader runs against this test Workspace.
box.OUTBOX_MOUNT_DIR = str(workspace / 'outbox')
return box, query, outbox
def result(stdout='[]', exit_code=0):
return BoxExecutionResult(
session_id='box-test',
backend_name='test',
status=BoxExecutionStatus.COMPLETED,
exit_code=exit_code,
stdout=stdout,
duration_ms=1,
)
async def execute_script(spec, *, action_context):
assert action_context.workspace_uuid == _CONTEXT.workspace_uuid
assert action_context.instance_uuid == _CONTEXT.instance_uuid
assert action_context.placement_generation == _CONTEXT.placement_generation
process = await asyncio.create_subprocess_exec(
'/bin/sh',
'-c',
spec.cmd,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
)
stdout, stderr = await asyncio.wait_for(process.communicate(), timeout=5)
return BoxExecutionResult(
session_id=spec.session_id,
backend_name='test',
status=BoxExecutionStatus.COMPLETED,
exit_code=process.returncode,
stdout=stdout.decode(),
stderr=stderr.decode(),
duration_ms=1,
)
@pytest.mark.asyncio
@pytest.mark.parametrize('kind', ['file', 'directory', 'query_directory'])
async def test_unreadable_output_is_not_empty_and_is_retained_when_fallback_fails(env, kind):
if os.geteuid() == 0:
pytest.skip('requires non-root filesystem permission checks')
box, query, outbox = env
nested = outbox / 'nested'
nested.mkdir()
file = nested / 'answer.txt'
file.write_bytes(b'expected')
unreadable = {'file': file, 'directory': nested, 'query_directory': outbox}[kind]
mode = unreadable.stat().st_mode & 0o777
box.client.execute = AsyncMock(side_effect=execute_script)
box._clear_outbox = AsyncMock()
try:
unreadable.chmod(0)
with pytest.raises(BoxError):
await box.collect_outbound_attachments(query)
box.client.execute.assert_awaited_once()
box._clear_outbox.assert_not_awaited()
finally:
unreadable.chmod(mode)
assert file.read_bytes() == b'expected'
@pytest.mark.asyncio
async def test_permission_fallback_retries_all_files_once_in_the_bound_box(env, monkeypatch):
box, query, outbox = env
(outbox / 'a.txt').write_bytes(b'first')
(outbox / 'b.txt').write_bytes(b'second')
original_open = secure_fs.os.open
def denied_open(path, flags, *args, **kwargs):
if path == 'b.txt' and not flags & os.O_DIRECTORY:
raise PermissionError(errno.EACCES, 'denied', path)
return original_open(path, flags, *args, **kwargs)
monkeypatch.setattr(secure_fs.os, 'open', denied_open)
monkeypatch.setattr(secure_fs.os, 'supports_dir_fd', secure_fs.os.supports_dir_fd | {denied_open})
box.client.execute = AsyncMock(side_effect=execute_script)
attachments = await box.collect_outbound_attachments(query)
assert {a['name']: base64.b64decode(a['base64']) for a in attachments} == {
'a.txt': b'first',
'b.txt': b'second',
}
assert list(outbox.iterdir()) == []
box.client.execute.assert_awaited_once()
spec = box.client.execute.await_args.args[0]
assert spec.session_id == query._box_binding.session_id
assert spec.host_path == str(outbox.parent.parent)
@pytest.mark.asyncio
@pytest.mark.parametrize('failure', ['transport', 'exit', 'empty', 'json', 'shape', 'base64', 'path', 'payload_limit'])
async def test_exec_failure_or_malformed_response_never_cleans_outbox(env, failure, monkeypatch):
box, query, outbox = env
file = outbox / 'answer.txt'
file.write_bytes(b'keep')
monkeypatch.setattr(box, '_host_query_dir', lambda *args: None)
box._clear_outbox = AsyncMock()
replies = {
'exit': result(exit_code=1),
'empty': result(''),
'json': result('not json'),
'shape': result('{}'),
'base64': result('[{"name":"a","b64":"!"}]'),
'path': result('[{"name":"../a","b64":"YQ=="}]'),
'payload_limit': result(json.dumps([{'name': 'a', 'b64': base64.b64encode(b'large').decode()}])),
}
if failure == 'payload_limit':
monkeypatch.setattr(box, '_ATTACHMENT_MAX_BYTES', 4)
box.client.execute = AsyncMock(
side_effect=RuntimeError('transport failure') if failure == 'transport' else None,
return_value=replies.get(failure),
)
with pytest.raises(BoxError):
await box.collect_outbound_attachments(query)
box._clear_outbox.assert_not_awaited()
assert file.read_bytes() == b'keep'
@pytest.mark.asyncio
@pytest.mark.parametrize('remote', [False, True])
@pytest.mark.parametrize('limit', ['file_bytes', 'total_bytes', 'file_count', 'depth', 'entries', 'directories'])
async def test_export_limit_failure_retains_every_file(env, monkeypatch, remote, limit):
box, query, outbox = env
if limit == 'file_bytes':
monkeypatch.setattr(box, '_ATTACHMENT_MAX_BYTES', 3)
if limit == 'total_bytes':
monkeypatch.setattr(box, '_ATTACHMENT_MAX_TOTAL_BYTES', 7)
if limit == 'file_count':
monkeypatch.setattr(box, '_ATTACHMENT_MAX_FILES', 1)
(outbox / 'one').write_bytes(b'1234')
(outbox / 'two').write_bytes(b'5678')
if limit == 'depth':
(outbox / Path(*(['nested'] * 9))).mkdir(parents=True)
if limit == 'entries':
for i in range(1001):
(outbox / f'link-{i}').symlink_to('one')
if limit == 'directories':
for i in range(65):
(outbox / f'directory-{i}').mkdir()
before = sorted(str(p.relative_to(outbox)) for p in outbox.rglob('*'))
box._clear_outbox = AsyncMock()
box.client.execute = AsyncMock(side_effect=execute_script)
if remote:
monkeypatch.setattr(box, '_host_query_dir', lambda *args: None)
with pytest.raises(BoxError):
await box.collect_outbound_attachments(query)
assert sorted(str(p.relative_to(outbox)) for p in outbox.rglob('*')) == before
assert (outbox / 'one').read_bytes() == b'1234'
assert (outbox / 'two').read_bytes() == b'5678'
box._clear_outbox.assert_not_awaited()
if not remote:
box.client.execute.assert_not_awaited()
@pytest.mark.asyncio
@pytest.mark.parametrize('remote', [False, True])
async def test_exact_limits_succeed_and_other_run_is_untouched(env, monkeypatch, remote):
box, query, outbox = env
monkeypatch.setattr(box, '_ATTACHMENT_MAX_FILES', 2)
monkeypatch.setattr(box, '_ATTACHMENT_MAX_BYTES', 4)
monkeypatch.setattr(box, '_ATTACHMENT_MAX_TOTAL_BYTES', 8)
(outbox / 'one').write_bytes(b'1234')
(outbox / 'two').write_bytes(b'5678')
other = outbox.parent / 'other-run'
other.mkdir()
(other / 'secret').write_bytes(b'other run')
(outbox / 'link').symlink_to(other / 'secret')
box.client.execute = AsyncMock(side_effect=execute_script)
if remote:
monkeypatch.setattr(box, '_host_query_dir', lambda *args: None)
box.execute_tool = AsyncMock(
side_effect=lambda *args: secure_fs.reset_directory(str(outbox.parent.parent), 'outbox', outbox.name)
)
attachments = await box.collect_outbound_attachments(query)
assert {a['name']: base64.b64decode(a['base64']) for a in attachments} == {'one': b'1234', 'two': b'5678'}
assert list(outbox.iterdir()) == []
assert (other / 'secret').read_bytes() == b'other run'
@pytest.mark.asyncio
async def test_unsafe_host_path_never_uses_privileged_fallback(env):
box, query, outbox = env
outbox.rmdir()
other = outbox.parent.parent / 'other-workspace'
other.mkdir()
(other / 'secret').write_bytes(b'private')
outbox.symlink_to(other)
box.client.execute = AsyncMock()
with pytest.raises(BoxValidationError, match='symbolic link'):
await box.collect_outbound_attachments(query)
box.client.execute.assert_not_awaited()
assert (other / 'secret').read_bytes() == b'private'
@pytest.mark.asyncio
@pytest.mark.parametrize('file_size,file_count', [(300 * 1024, 1), (1024 * 1024, 6)])
async def test_exec_export_preserves_upstream_large_attachment_limits(env, monkeypatch, file_size, file_count):
box, query, outbox = env
payload = b'x' * file_size
for index in range(file_count):
(outbox / f'file-{index}.bin').write_bytes(payload)
monkeypatch.setattr(box, '_host_query_dir', lambda *args: None)
box.client.execute = AsyncMock(side_effect=execute_script)
box.execute_tool = AsyncMock(
side_effect=lambda *args: secure_fs.reset_directory(str(outbox.parent.parent), 'outbox', outbox.name)
)
attachments = await box.collect_outbound_attachments(query)
assert {a['name']: base64.b64decode(a['base64']) for a in attachments} == {
f'file-{index}.bin': payload for index in range(file_count)
}
assert list(outbox.iterdir()) == []
@pytest.mark.asyncio
async def test_host_io_error_is_not_retried_as_a_permission_failure(env, monkeypatch):
box, query, outbox = env
file = outbox / 'answer.txt'
file.write_bytes(b'keep')
original = secure_fs.os.open
def fail_open(path, flags, *args, **kwargs):
if path == 'answer.txt':
raise OSError(errno.EIO, 'I/O failure')
return original(path, flags, *args, **kwargs)
monkeypatch.setattr(secure_fs.os, 'open', fail_open)
monkeypatch.setattr(secure_fs.os, 'supports_dir_fd', secure_fs.os.supports_dir_fd | {fail_open})
box.client.execute = AsyncMock()
box._clear_outbox = AsyncMock()
with pytest.raises(BoxError, match='retained'):
await box.collect_outbound_attachments(query)
box.client.execute.assert_not_awaited()
box._clear_outbox.assert_not_awaited()
assert file.read_bytes() == b'keep'
@pytest.mark.asyncio
async def test_cancelled_export_does_not_clear_output(env, monkeypatch):
box, query, outbox = env
(outbox / 'answer.txt').write_bytes(b'keep')
monkeypatch.setattr(box, '_host_query_dir', lambda *args: None)
box.client.execute = AsyncMock(side_effect=asyncio.CancelledError())
box._clear_outbox = AsyncMock()
with pytest.raises(asyncio.CancelledError):
await box.collect_outbound_attachments(query)
box._clear_outbox.assert_not_awaited()
assert (outbox / 'answer.txt').read_bytes() == b'keep'
@pytest.mark.asyncio
async def test_stale_workspace_result_does_not_clear_output(env, monkeypatch):
box, query, outbox = env
(outbox / 'answer.txt').write_bytes(b'keep')
monkeypatch.setattr(box, '_host_query_dir', lambda *args: None)
binding = box.ap.workspace_service.get_execution_binding.return_value
box.ap.workspace_service.get_execution_binding.side_effect = [binding, BoxError('stale generation')]
box.client.execute = AsyncMock(return_value=result('[{"name":"answer.txt","b64":"a2VlcA=="}]'))
box._clear_outbox = AsyncMock()
with pytest.raises(BoxError, match='stale generation'):
await box.collect_outbound_attachments(query)
box._clear_outbox.assert_not_awaited()
assert (outbox / 'answer.txt').read_bytes() == b'keep'
@pytest.mark.asyncio
@pytest.mark.parametrize('limit', ['files', 'bytes'])
async def test_run_limit_failure_preserves_files_and_export_references_for_retry(env, monkeypatch, limit):
from langbot.pkg.box.runner import RunnerBoxService, binding_for
box, query, outbox = env
api = RunnerBoxService(box)
box.client.execute = AsyncMock(side_effect=execute_script)
binding = binding_for(query)
(outbox / 'one').write_bytes(b'ab')
(outbox / 'two').write_bytes(b'cd')
if limit == 'files':
binding.exported.update({f'existing-{i}': {'size': 1} for i in range(99)})
else:
monkeypatch.setattr(box, '_ATTACHMENT_MAX_TOTAL_BYTES', 5)
binding.exported['existing'] = {'size': 2}
before = dict(binding.exported)
with pytest.raises(BoxValidationError):
await api.export_files(query, [str(p) for p in outbox.iterdir()])
assert binding.exported == before
assert (outbox / 'one').read_bytes() == b'ab'
assert (outbox / 'two').read_bytes() == b'cd'
# After the caller reduces its output, it can retry without duplicate IDs.
(outbox / 'two').unlink()
exported = await api.export_files(query, [str(p) for p in outbox.iterdir()])
assert len(binding.exported) == len(before) + 1
assert any(item['name'] == 'one' for item in exported['items'] if 'name' in item)
assert list(outbox.iterdir()) == []
@pytest.mark.asyncio
async def test_unavailable_box_does_not_turn_runner_export_into_empty_cleanup(env):
from langbot.pkg.box.runner import RunnerBoxService
box, query, outbox = env
box._available = False
(outbox / 'answer.txt').write_bytes(b'keep')
with pytest.raises(BoxError, match='not available'):
await RunnerBoxService(box).export_files(query, [str(outbox / 'answer.txt')])
assert (outbox / 'answer.txt').read_bytes() == b'keep'
@pytest.mark.asyncio
@pytest.mark.parametrize('remote', [False, True])
async def test_export_diagnostics_include_limit_actual_relative_path_and_retention(env, monkeypatch, remote):
from langbot.pkg.box.export_errors import BoxExportError
box, query, outbox = env
(outbox / 'nested').mkdir()
(outbox / 'nested' / 'answer.txt').write_bytes(b'1234')
monkeypatch.setattr(box, '_ATTACHMENT_MAX_BYTES', 3)
box.client.execute = AsyncMock(side_effect=execute_script)
if remote:
monkeypatch.setattr(box, '_host_query_dir', lambda *args: None)
with pytest.raises(BoxExportError) as caught:
await box.collect_outbound_attachments(query)
error = caught.value.error
assert error.code == 'box.export.limit_exceeded' and error.retryable
assert error.details['limit_type'] == 'file_bytes'
assert error.details['limit'] == 3 and error.details['actual'] == 4
assert error.details['relative_paths'] == ['nested/answer.txt']
assert error.details['files_retained'] is True and error.details['suggested_action']
assert (outbox / 'nested' / 'answer.txt').read_bytes() == b'1234'
@pytest.mark.asyncio
@pytest.mark.parametrize(
'stdout,code,actual',
[
('[{"name":', 'box.export.invalid_response', None),
('not json', 'box.export.invalid_response', None),
(
'[{\n... [raw output clipped at 1048576 bytes, 12 bytes discarded]',
'box.export.transport_truncated',
1048588,
),
('[{\n... [output clipped at 1048576 bytes]', 'box.export.transport_truncated', None),
],
)
async def test_only_explicit_transport_clipping_is_repairable(env, monkeypatch, stdout, code, actual):
from langbot.pkg.box.export_errors import BoxExportError
box, query, outbox = env
(outbox / 'answer.txt').write_bytes(b'keep')
monkeypatch.setattr(box, '_host_query_dir', lambda *args: None)
box.client.execute = AsyncMock(return_value=result(stdout))
with pytest.raises(BoxExportError) as caught:
await box.collect_outbound_attachments(query)
assert caught.value.error.code == code
assert caught.value.error.retryable is (code == 'box.export.transport_truncated')
assert caught.value.error.details['actual'] == actual
assert (outbox / 'answer.txt').read_bytes() == b'keep'
def test_untrusted_script_error_is_not_classified_as_a_quota_failure():
from langbot.pkg.box.export_errors import script_export_error
payload = 'LANGBOT_OUTBOX_LIMIT:' + json.dumps({'kind': 'file_count', 'limit': 20, 'actual': 21, 'path': 'a.txt'})
assert script_export_error(payload, 73, {'file_count': 20}) is not None
assert script_export_error(payload, 1, {'file_count': 20}) is None
assert script_export_error(payload, 73, {'file_count': 10}) is None
assert script_export_error(payload.replace('a.txt', '../a'), 73, {'file_count': 20}) is None
@pytest.mark.asyncio
async def test_selected_file_quota_reports_absolute_sandbox_path_and_retains_batch(env, monkeypatch):
from langbot.pkg.box.export_errors import BoxExportError
box, query, outbox = env
(outbox / 'answer.txt').write_bytes(b'1234')
(outbox / 'keep.txt').write_bytes(b'unselected')
monkeypatch.setattr(box, '_ATTACHMENT_MAX_BYTES', 3)
box.client.execute = AsyncMock(side_effect=execute_script)
path = str(outbox / 'answer.txt')
with pytest.raises(BoxExportError) as caught:
await box.collect_selected_files(query, [path])
details = caught.value.error.details
assert details['limit_type'] == 'file_bytes'
assert details['limit'] == 3 and details['actual'] == 4
assert details['sandbox_paths'] == [path] and details['relative_paths'] == []
assert details['files_retained'] is True
assert (outbox / 'answer.txt').read_bytes() == b'1234'
assert (outbox / 'keep.txt').read_bytes() == b'unselected'
@pytest.mark.asyncio
async def test_directory_export_still_rejects_absolute_response_paths(env, monkeypatch):
from langbot.pkg.box.export_errors import BoxExportError
box, query, outbox = env
monkeypatch.setattr(box, '_host_query_dir', lambda *args: None)
box.client.execute = AsyncMock(return_value=result('[{"name":"/unrequested","b64":"YQ=="}]'))
with pytest.raises(BoxExportError) as caught:
await box.collect_outbound_attachments(query)
assert caught.value.error.code == 'box.export.invalid_response'
@@ -158,7 +158,7 @@ def racing_open(path, flags, *args, **kwargs):
if replacement == 'symlink':
os.symlink({str(secret)!r}, path, dir_fd=fd)
elif replacement == 'fifo':
os.mkfifo(path, dir_fd=fd)
os.mkfifo({str(current / 'answer.txt')!r})
else:
os.mkdir(path, dir_fd=fd)
child = original_open(path, os.O_RDONLY | os.O_DIRECTORY, dir_fd=fd)
@@ -205,24 +205,25 @@ def test_sandbox_output_limits_fail_without_removing_files(tmp_path, limit):
assert set(current.iterdir()) == before
def test_sandbox_output_count_and_depth_are_bounded(tmp_path):
import json
def test_sandbox_output_count_and_depth_limits_retain_all_files(tmp_path):
current = tmp_path / 'workspace' / 'outbox' / 'run'
current.mkdir(parents=True)
(current / 'a.txt').write_bytes(b'a')
(current / 'b.txt').write_bytes(b'b')
result = _run_script(_script(tmp_path, max_files=1))
assert result.returncode == 0, result.stderr
assert len(json.loads(result.stdout)) == 1
assert result.returncode != 0
assert 'file-count limit' in result.stderr
assert (current / 'a.txt').read_bytes() == b'a'
assert (current / 'b.txt').read_bytes() == b'b'
(current / 'a.txt').unlink()
(current / 'b.txt').unlink()
deep = current.joinpath(*('nested' for _ in range(9)))
deep.mkdir(parents=True)
(deep / 'too-deep.txt').write_bytes(b'not exported')
result = _run_script(_script(tmp_path))
assert result.returncode == 0, result.stderr
assert json.loads(result.stdout) == []
assert result.returncode != 0
assert 'depth limit' in result.stderr
assert (deep / 'too-deep.txt').read_bytes() == b'not exported'
@pytest.mark.parametrize('operation', ['read', 'clear'])
+61 -5
View File
@@ -21,6 +21,8 @@ def service():
enabled=True,
managed_admission_required=False,
_ATTACHMENT_MAX_TOTAL_BYTES=1000000,
clear_selected_files=AsyncMock(return_value=None),
_ATTACHMENT_MAX_FILES=100,
create_session=AsyncMock(side_effect=create),
require_workspace_sandbox=AsyncMock(side_effect=lambda context: context),
_action_context=lambda context: context,
@@ -110,12 +112,12 @@ async def test_selective_import_is_idempotent_and_separates_duplicate_names():
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(
api.box.collect_selected_files = AsyncMock(
return_value=[
{'name': 'answer.txt', 'type': 'File', 'base64': base64.b64encode(b'answer').decode()},
]
)
result = await api.export_files(q)
result = await api.export_files(q, ['/workspace/a'])
file = result['items'][0]
assert 'base64' not in file and file['size'] == 6
other = SimpleNamespace(_box_binding=RunBoxBinding('other-run', 'box', {}, 'other-run'))
@@ -172,11 +174,65 @@ def test_attachment_references_follow_metadata_not_platform_list_order():
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(
api.box.collect_selected_files = AsyncMock(
return_value=[{'name': 'a.png', 'type': 'Image', 'base64': 'data:image/png;base64,YQ=='}]
)
result = await api.export_files(q)
result = await api.export_files(q, ['/workspace/a'])
assert result['items'][0]['size'] == 1
# Retrying unchanged content reuses the accepted ID; new content consumes budget.
assert await api.export_files(q, ['/workspace/a']) == result
api.box.collect_selected_files.return_value[0]['base64'] = 'data:image/png;base64,Yg=='
api.box._ATTACHMENT_MAX_TOTAL_BYTES = 1
with pytest.raises(BoxValidationError, match='byte limit'):
await api.export_files(q)
await api.export_files(q, ['/workspace/a'])
@pytest.mark.asyncio
async def test_cleanup_retry_preserves_ids_and_never_redelivers_consumed_files():
api = service()
q = SimpleNamespace(_box_binding=RunBoxBinding('run', 'box', {}, 'run'))
# A best-effort cleanup can leave the same files in the outbox.
api.box.collect_selected_files = AsyncMock(
return_value=[
{'name': 'same.txt', '_outbox_path': 'a/same.txt', 'type': 'File', 'base64': 'YQ=='},
{'name': 'same.txt', '_outbox_path': 'b/same.txt', 'type': 'File', 'base64': 'YQ=='},
{'name': 'empty.txt', '_outbox_path': 'empty.txt', 'type': 'File', 'base64': ''},
]
)
first = await api.export_files(q, ['/workspace/a'])
assert len(first['items']) == 3
assert all('_outbox_path' not in item for item in first['items'])
assert await api.export_files(q, ['/workspace/a']) == first
exported_message(q, [item['id'] for item in first['items']], consume=True)
assert await api.export_files(q, ['/workspace/a']) == {'items': [], 'warning': None}
assert len(q._box_binding.exported) == 3
api.box.collect_selected_files.return_value[0]['base64'] = 'Yg=='
changed = await api.export_files(q, ['/workspace/a'])
assert len(changed['items']) == 1
assert changed['items'][0]['id'] not in {item['id'] for item in first['items']}
@pytest.mark.asyncio
async def test_run_quota_failure_is_atomic_and_reports_remaining_budget():
from langbot.pkg.box.export_errors import BoxExportError
api = service()
api.box._ATTACHMENT_MAX_TOTAL_BYTES = 2
q = SimpleNamespace(_box_binding=RunBoxBinding('run', 'box', {}, 'run'))
api.box.collect_selected_files = AsyncMock(
return_value=[
{'name': 'a.txt', 'type': 'File', 'base64': 'YQ=='},
]
)
await api.export_files(q, ['/workspace/a'])
api.box.collect_selected_files.return_value = [
{'name': 'b.txt', 'type': 'File', 'base64': 'Yg=='},
{'name': 'c.txt', 'type': 'File', 'base64': 'Yw=='},
]
with pytest.raises(BoxExportError) as caught:
await api.export_files(q, ['/workspace/a'])
assert caught.value.error.details['limit'] == 2
assert caught.value.error.details['actual'] == 3
assert caught.value.error.details['remaining'] == 1
assert len(q._box_binding.exported) == len(q._box_binding.exported_fingerprints) == 1
api.box.clear_selected_files.assert_awaited_once()
@@ -0,0 +1,57 @@
import hashlib
import json
import subprocess
import sys
import pytest
from langbot.pkg.box.selected_files import selected_files_script
def run(paths, cleanup=None, limit=100):
return subprocess.run(
[
sys.executable,
'-c',
selected_files_script(list(map(str, paths)), max_file_bytes=limit, max_total_bytes=limit, cleanup=cleanup),
],
capture_output=True,
text=True,
)
def test_selected_read_and_cleanup(tmp_path):
a, b = tmp_path / 'a', tmp_path / 'b'
a.write_bytes(b'hello')
b.write_bytes(b'keep')
result = run([a])
assert result.returncode == 0, result.stderr
assert json.loads(result.stdout) == [{'name': str(a), 'b64': 'aGVsbG8='}]
assert a.exists() and b.exists()
assert run([a], {str(a): hashlib.sha256(b'hello').hexdigest()}).returncode == 0
assert not a.exists() and b.read_bytes() == b'keep'
@pytest.mark.parametrize('bad', ['missing', 'symlink', 'directory', 'size', 'fifo'])
def test_failed_batch_retains_all(tmp_path, bad):
a, b = tmp_path / 'a', tmp_path / 'b'
a.write_bytes(b'hello')
if bad == 'symlink':
b.symlink_to(a)
elif bad == 'directory':
b.mkdir()
elif bad == 'size':
b.write_bytes(b'x' * 101)
elif bad == 'fifo':
import os
os.mkfifo(b)
result = run([a, b])
assert result.returncode != 0
assert a.read_bytes() == b'hello'
def test_changed_file_is_retained(tmp_path):
a = tmp_path / 'a'
a.write_bytes(b'changed')
assert run([a], {str(a): hashlib.sha256(b'old').hexdigest()}).returncode != 0
assert a.read_bytes() == b'changed'