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
@@ -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()
|
||||
Reference in New Issue
Block a user