mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-10-23 17:05:28 +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
@@ -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
|
||||
]
|
||||
],
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user