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
+58 -10
View File
@@ -12,6 +12,7 @@ from dataclasses import dataclass, field, replace
from langbot_plugin.api.entities.builtin.platform import message as pm
from langbot_plugin.api.entities.builtin.runner.box import BoxAcquireRequest
from langbot_plugin.box.errors import BoxValidationError
from .export_errors import export_error
@dataclass
@@ -25,6 +26,7 @@ class RunBoxBinding:
submitted: set = field(default_factory=set)
lock: asyncio.Lock = field(default_factory=asyncio.Lock)
read_only_mounts: list[dict] = field(default_factory=list)
exported_fingerprints: dict = field(default_factory=dict)
def prepare_input_files(query, agent_input):
@@ -213,26 +215,72 @@ class RunnerBoxService:
binding.imported[key] = {'id': key, **items[0]}
return {'items': [binding.imported[key] for key in ids]}
async def export_files(self, query):
async def export_files(self, query, paths):
binding = binding_for(query)
if not isinstance(paths, list) or not paths or len(paths) > self.box._ATTACHMENT_MAX_FILES:
raise BoxValidationError(f'paths must contain 1 to {self.box._ATTACHMENT_MAX_FILES} file paths')
if any(
not isinstance(p, str)
or not p.startswith('/')
or '\\' in p
or '\x00' in p
or any(c in {'', '.', '..'} for c in p.split('/')[1:])
for p in paths
):
raise BoxValidationError('paths must be normalized absolute sandbox file paths')
if len(set(paths)) != len(paths):
raise BoxValidationError('Duplicate export file paths')
async with binding.lock:
items = await self.box.collect_outbound_attachments(query)
items = await self.box.collect_selected_files(query, paths)
unseen = {}
for item in items:
if len(binding.exported) >= 100:
raise BoxValidationError('At most 100 output files may be exported per run')
size = len(base64.b64decode(item['base64'].split(';base64,')[-1]))
if sum(x['size'] for x in binding.exported.values()) + size > self.box._ATTACHMENT_MAX_TOTAL_BYTES:
raise BoxValidationError('Output files exceed the per-run byte limit')
data = base64.b64decode(item['base64'].split(';base64,')[-1])
fingerprint = (item.get('_outbox_path', item['name']), item['type'], hashlib.sha256(data).hexdigest())
if fingerprint not in binding.exported_fingerprints:
unseen[fingerprint] = (item, len(data))
if len(binding.exported) + len(unseen) > 100:
first = next(iter(unseen.values()))[0]
raise export_error(
'run_files',
limit=100,
actual=len(binding.exported) + len(unseen),
remaining=max(0, 100 - len(binding.exported)),
path=first.get('_outbox_path', first['name']),
absolute_path=True,
)
total_bytes = sum(x['size'] for x in binding.exported.values())
batch_bytes = sum(size for _, size in unseen.values())
if unseen and total_bytes + batch_bytes > self.box._ATTACHMENT_MAX_TOTAL_BYTES:
first = next(iter(unseen.values()))[0]
raise export_error(
'run_bytes',
limit=self.box._ATTACHMENT_MAX_TOTAL_BYTES,
actual=total_bytes + batch_bytes,
remaining=max(0, self.box._ATTACHMENT_MAX_TOTAL_BYTES - total_bytes),
path=first.get('_outbox_path', first['name']),
absolute_path=True,
)
pending = {}
fingerprints = {}
for fingerprint, (item, size) in unseen.items():
key = str(uuid.uuid4())
binding.exported[key] = {
pending[key] = {
**item,
'id': key,
'size': size,
}
fingerprints[fingerprint] = key
binding.exported.update(pending)
binding.exported_fingerprints.update(fingerprints)
try:
warning = await self.box.clear_selected_files(query, items)
except Exception:
warning = 'Files accepted, but cleanup failed; do not repeat delivery.'
return {
'warning': warning,
'items': [
{k: v for k, v in item.items() if k != 'base64'}
{k: v for k, v in item.items() if k not in {'base64', '_outbox_path'}}
for key, item in binding.exported.items()
if key not in binding.submitted
]
],
}