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
@@ -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
@@ -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()
|
||||
@@ -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