mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-10-22 16:35:20 +08:00
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:
co-authored by
multica-agent
HaiYangBG1
Chin Rock
parent
bce94bd0d9
commit
947d8addea
@@ -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'
|
||||
|
||||
|
||||
@@ -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'])
|
||||
|
||||
@@ -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'
|
||||
Reference in New Issue
Block a user