mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-10-22 08:25:21 +08:00
* 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]>
112 lines
5.3 KiB
Python
112 lines
5.3 KiB
Python
"""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()
|