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):
+11 -11
View File
@@ -1,9 +1,9 @@
{
"repository": "langbot-app/langbot-plugins",
"revision": "7ad54b1a139d88113bc55128326800ed29779737",
"revision": "998b822c412d2604f3c5eca91f04519d98a51b15",
"contract": "Current dedicated-workspace and trusted shared-installation identity; see each plugin SHARED_RUNTIME.md.",
"files": {
"Runner/dify-agent/components/runner/default.py": "0bcaff74c64c89be2bddb4be5871ce148a814676b50987a65f95228ecb7da8fa",
"Runner/dify-agent/components/runner/default.py": "8ca64052fb8df872272726aa060e21ba3b4afa454346b6babcc7e9d2df600980",
"Runner/dify-agent/pkg/scoped_identity.py": "5d03d0c56aaf3301bc5d594905ecf862151fa2abc73daba1a6596f0fdc16374a",
"Runner/dify-agent/pkg/dify_client.py": "607ed8d610d916e5b64ed20699e3de6e2216b238a5fecc9cabcdb75533b8dc56",
"Runner/n8n-agent/components/runner/default.py": "2719ec0a8ff779e90e61bd0209653fb9a1eab4b552877cfcbd4d3c9ce803fcf1",
@@ -18,24 +18,24 @@
},
"local_agent_files": {
"Runner/LocalAgent/assets/icon.svg": "7e1cb51793e898fca810ec72ba6fe9af377074f52139cdd56c93e87f3459f964",
"Runner/LocalAgent/components/runner/default.py": "c63dc2697c61184e496d2fbed9808ccac23fb706a7dcccf34292da2b8e0079b9",
"Runner/LocalAgent/components/runner/default.py": "e548096b910e1faaa4a97a2f7f3e63e42a8227bc789e296ff546c07ab27f3e55",
"Runner/LocalAgent/components/runner/default.yaml": "c1364cf0f211162bb701e27ed92b351fd9ff4cfa0b4469cfeb83ad58136cdf47",
"Runner/LocalAgent/main.py": "0cde45d7f6f411563fbded09be70d0a497c7c7999b5eaa27f83052383348277f",
"Runner/LocalAgent/manifest.yaml": "e71e03f2670dade9ae43e58ddf787bb87254ba9f05e1a691822679f3641bb885",
"Runner/LocalAgent/pkg/__init__.py": "ce5142b4971e8d7945fb3c202bacbc28011b033de0ddcb612d5d29106d6985ec",
"Runner/LocalAgent/pkg/agent_core/__init__.py": "56e95f2391f796e17aee6a168603853ea78b59df97a136bfa33532aede2fa413",
"Runner/LocalAgent/pkg/agent_core/langbot.py": "dc16818ac81dacf37dd6ee799dec4fcd8235ffab4602ede30d29633e78626fc0",
"Runner/LocalAgent/pkg/agent_core/loop.py": "47451d5935d79b0a632fc87f3d94a7d0ecf1b50621141ea8b1031dd87755d5ad",
"Runner/LocalAgent/pkg/agent_core/types.py": "df82b1a17cd2b73f65ebbde22ab8f204ffd27b8d68814b404a1ecce9988e1fcf",
"Runner/LocalAgent/pkg/box.py": "82c05a55bd59b41e844f4613563e518768921fe4fd34bcb4c14c86e395ccd021",
"Runner/LocalAgent/pkg/agent_core/langbot.py": "6bc0e6ee89e8ba3705d14545169f25f54a0c56dc5da89bd8384b3e22e3f21fea",
"Runner/LocalAgent/pkg/agent_core/loop.py": "6a3d67945dee199b4820a9788b207e148355420fa1216bbb82face2fe361394f",
"Runner/LocalAgent/pkg/agent_core/types.py": "78053571e662fd16d42b9f6bd506f282c54adec317cbc8cbf6ed0deab80df4e0",
"Runner/LocalAgent/pkg/box.py": "9ddc2e670234a4be0d0946d9acc376748c8a6fa7714f47c30f8355a1739e499f",
"Runner/LocalAgent/pkg/config.py": "8dee31aecbcdba4c1e79e1735499c3c7ee826ec395032b045169652b38216faf",
"Runner/LocalAgent/pkg/context_pipeline.py": "4c6da3c48faf28985b8bad013fe15106705cd3e025f0c55708b463e4ea07eb05",
"Runner/LocalAgent/pkg/context_pipeline.py": "8f338aa10e9ee384719f215f6e87607baa6caae970fd0aab04303103502c5298",
"Runner/LocalAgent/pkg/messages.py": "620a0a225e95c5f2c8c6e977801413e553d4a1da3fb2ddea14e527ad41f7a3af",
"Runner/LocalAgent/pkg/model_calling.py": "29989b102c8f649e1e65c7c5719761ed39d357d37d57bfa321976d48fb11b76c",
"Runner/LocalAgent/pkg/model_calling.py": "3ebdb8900bb0de5b14e84bf027cc4ff01e59a4669d28dcede7ca3dcea3a3de4c",
"Runner/LocalAgent/pkg/rag.py": "ea407af8d4f6fe5b00cde72c7d85ccc541d6fc46254188c372351d93da4a1f0f",
"Runner/LocalAgent/pkg/run_assembly.py": "ab08c6a18a108cfc6048f158b09ad874577451072dce0bbaa3aa9d15c33a3afe",
"Runner/LocalAgent/pyproject.toml": "ef14f2ed5d4d78591d2f1c0ac0f05856d558dae71da330a203abf3160356e6b1",
"Runner/LocalAgent/pyproject.toml": "f518d499922583c140d5f279bfaacc6f50c1b576e5cec15d170199266820ef84",
"Runner/LocalAgent/requirements.txt": "dd4542c179fa98c8341e7a0b582eb741a70633cd621f4509d09a9ff5c7e52c76",
"Runner/LocalAgent/uv.lock": "4c3c7655ef8c3984e7b720dea346ea67b4d29e8c6d543037779008c3bca074a8"
"Runner/LocalAgent/uv.lock": "39d5851bd787d5207cad0b853b123b02e938fa00124d6f48e2f07c9f3bb770c2"
}
}
@@ -0,0 +1,90 @@
"""Opt-in live model -> Docker -> Core export acceptance test.
Requires BOX_LIVE_API_KEY and BOX_LIVE_MODEL; never records credentials.
"""
import base64
import json
import logging
import os
import httpx
import pytest
from langbot.pkg.box.runner import RunnerBoxService, binding_for, exported_message
from langbot.pkg.box.service import BoxService
from langbot_plugin.box.runtime import BoxRuntime
from tests.integration_tests.box.test_outbox_export import _TestDockerBackend, _Client
from tests.unit_tests.box.test_box_service import make_app, make_query, _CONTEXT
@pytest.mark.asyncio
async def test_live_model_explicit_export(tmp_path):
key = os.environ.get('BOX_LIVE_API_KEY')
if not key:
pytest.skip('BOX_LIVE_API_KEY is required')
logger = logging.getLogger('test.box.live')
backend = _TestDockerBackend(logger)
assert await backend.is_available(), 'Docker required'
app = make_app(logger, allowed_mount_roots=[str(tmp_path)], host_root=str(tmp_path / 'box'))
app.instance_config.data['box']['backend'] = 'docker'
app.instance_config.data['box']['local']['image'] = 'python:3.12-alpine'
runtime = BoxRuntime(logger=logger, backends=[backend], max_sessions=1)
box = BoxService(app, client=_Client(logger, runtime))
api = RunnerBoxService(box)
await box.initialize()
tools = [
{'type': 'function', 'function': {'name': 'exec', 'description': 'Run shell in sandbox',
'parameters': {'type': 'object', 'properties': {'command': {'type': 'string'}}, 'required': ['command']}}},
{'type': 'function', 'function': {'name': 'export_box_files',
'description': 'Export selected absolute sandbox file paths. Success accepts files and removes only selected files. Failure preserves files; fix the problem and retry.',
'parameters': {'type': 'object', 'properties': {'paths': {'type': 'array', 'items': {'type': 'string'}}}, 'required': ['paths']}}},
]
try:
acquired = await api.acquire(_CONTEXT, {'reuse_key': 'live-export'})
query = make_query(0)
object.__setattr__(query, '_box_binding', None)
await api.bind(_CONTEXT, query, 'live-explicit-export', acquired['id'])
outbox = f'/workspace/outbox/{binding_for(query).io_scope}'
target = outbox + '/answer.txt'
keep = outbox + '/keep.txt'
messages = [{'role': 'user', 'content': (
f'Use tools to create {target} containing exactly LIVE_EXPORT_OK (no newline) and {keep} containing KEEP. '
'First deliberately attempt to export /workspace/does-not-exist.txt to test error feedback. '
f'After that fails, explicitly export only {target}. Do not export or delete {keep}. '
'Then finish. Prefer outbox for deliverables; file creation alone does not deliver them.'
)}]
errors = 0
items = []
async with httpx.AsyncClient(timeout=120) as client:
for _ in range(12):
response = await client.post(
os.environ.get('BOX_LIVE_BASE_URL', 'https://newapi.rockchin.top/v1') + '/chat/completions',
headers={'Authorization': 'Bearer ' + key},
json={'model': os.environ['BOX_LIVE_MODEL'], 'messages': messages, 'tools': tools},
)
assert response.status_code == 200, f'Gateway HTTP {response.status_code}'
message = response.json()['choices'][0]['message']
messages.append(message)
calls = message.get('tool_calls', [])
if not calls:
break
for call in calls:
args = json.loads(call['function']['arguments'])
try:
if call['function']['name'] == 'exec':
result = await box.execute_tool(args, query)
else:
result = await api.export_files(query, args['paths'])
items.extend(result['items'])
except Exception as exc:
errors += 1
result = {'error': str(exc)}
messages.append({'role': 'tool', 'tool_call_id': call['id'], 'content': json.dumps(result)})
assert errors >= 1, 'Model did not exercise the requested failing export'
assert len(items) == 1
message = exported_message(query, [items[0]['id']])
assert base64.b64decode(message.root[0].base64) == b'LIVE_EXPORT_OK'
result = await box.execute_tool({'command': f'test ! -e {target} && cat {keep}'}, query)
assert result['ok'] and result['stdout'] == 'KEEP'
finally:
await box.shutdown()
@@ -0,0 +1,111 @@
"""Real Docker file export through Core and the SDK, with ownership evidence."""
import base64
import json
import logging
import os
import shlex
import sys
from unittest.mock import AsyncMock
import pytest
from langbot.pkg.box.runner import RunnerBoxService, binding_for, exported_message
from langbot.pkg.box.service import BoxService
from langbot_plugin.api.entities.builtin.platform.message import File, MessageChain
from langbot_plugin.box.backend import DockerBackend
from langbot_plugin.box.runtime import BoxRuntime
from tests.unit_tests.box.test_box_service import _InProcessBoxRuntimeClient, make_app, make_query, _CONTEXT
class _TestDockerBackend(DockerBackend):
async def cleanup_orphaned_containers(self, current_instance_id=''):
# Never inspect or remove another developer's Box containers.
pass
class _Client(_InProcessBoxRuntimeClient):
async def create_session(self, spec, *, action_context=None):
return await self._runtime.create_session(spec, action_context=action_context)
async def execute(self, spec, *, action_context=None):
return await self._runtime.execute(spec, action_context=action_context)
async def get_sessions(self, *, action_context=None):
return self._runtime.get_sessions_for_workspace(action_context)
@pytest.mark.asyncio
async def test_root_owned_output_exports_from_reused_box_without_cross_run_leaks(tmp_path, capfd):
logger = logging.getLogger('test.box.outbox')
backend = _TestDockerBackend(logger)
if not await backend.is_available():
pytest.skip('Docker is required for file ownership/export integration')
if sys.platform == 'linux' and os.geteuid() == 0:
pytest.skip('the Linux ownership regression requires non-root Core')
app = make_app(logger, allowed_mount_roots=[str(tmp_path)], host_root=str(tmp_path / 'box'))
app.instance_config.data['box']['backend'] = 'docker'
app.instance_config.data['box']['local']['image'] = 'python:3.12-alpine'
runtime = BoxRuntime(logger=logger, backends=[backend], max_sessions=1)
box = BoxService(app, client=_Client(logger, runtime))
api = RunnerBoxService(box)
await box.initialize()
original_reader = box._read_outbox_via_exec
box._read_outbox_via_exec = AsyncMock(wraps=original_reader)
try:
first_box = await api.acquire(_CONTEXT, {'reuse_key': 'export-regression'})
previous_file_id = None
for index in range(2):
acquired = await api.acquire(_CONTEXT, {'reuse_key': 'export-regression'})
assert acquired['id'] == first_box['id']
query = make_query(index)
object.__setattr__(query, '_box_binding', None)
await api.bind(_CONTEXT, query, f'export-run-{index}', acquired['id'])
payload = f'exact input for run {index}'.encode()
query.message_chain = MessageChain([File(name='input.txt', base64=base64.b64encode(payload).decode())])
imported = await api.import_attachments(query, None)
source = imported['items'][0]['path']
outbox = f'/workspace/outbox/{binding_for(query).io_scope}'
target = outbox + '/answer.txt'
copied = await box.execute_tool(
{'command': f'mkdir -p {shlex.quote(outbox)} && cp {shlex.quote(source)} {shlex.quote(target)}'},
query,
)
assert copied['ok'], copied
stat_script = (
'import json, os, stat; '
f's = os.stat({target!r}); '
'print(json.dumps(dict(uid=s.st_uid, gid=s.st_gid, mode=stat.S_IMODE(s.st_mode))))'
)
evidence = await box.execute_tool({'command': 'python3 -c ' + shlex.quote(stat_script)}, query)
assert evidence['ok'], evidence
metadata = json.loads(evidence['stdout'])
# Keep ownership evidence in CI logs even when the test passes.
with capfd.disabled():
print(f'Core uid={os.geteuid()}; Docker copy: {metadata}', flush=True)
assert metadata['mode'] == 0o600
if sys.platform == 'linux':
# Linux CI exercises the issue's exact root-owned 0600 case.
assert metadata['uid'] == 0
assert metadata['gid'] == 0
# Explicit selections always use the reader inside the bound Box.
exported = await api.export_files(query, [target])
assert len(exported['items']) == 1
item = exported['items'][0]
assert item['size'] == len(payload)
assert item['name'] == 'answer.txt'
message = exported_message(query, [item['id']])
assert base64.b64decode(message.root[0].base64) == payload
if previous_file_id:
from langbot_plugin.box.errors import BoxValidationError
with pytest.raises(BoxValidationError, match='does not belong'):
exported_message(query, [previous_file_id])
previous_file_id = item['id']
remaining = await box.execute_tool({'command': f'find {shlex.quote(outbox)} -type f'}, query)
assert remaining['ok'], remaining
assert remaining['stdout'].strip() == ''
assert len(await api.sessions(_CONTEXT)) == 1
assert box._read_outbox_via_exec.await_count == 2
finally:
await box.shutdown()
+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'