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