mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-10-22 16:35:20 +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]>
287 lines
13 KiB
Python
287 lines
13 KiB
Python
"""Runner-owned Box selection over Host-owned execution and file services."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import copy
|
|
import base64
|
|
import hashlib
|
|
import uuid
|
|
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
|
|
class RunBoxBinding:
|
|
run_id: str
|
|
session_id: str
|
|
spec: dict
|
|
io_scope: str
|
|
imported: dict = field(default_factory=dict)
|
|
exported: dict = field(default_factory=dict)
|
|
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):
|
|
"""Retain provider inputs and issue references without downloading files.
|
|
|
|
Only platform-origin components may carry a Host-local path. A path in an
|
|
event envelope is metadata, never permission to read the Host filesystem.
|
|
"""
|
|
from langbot_plugin.api.entities.builtin.runner.input import InputAttachment
|
|
from ..agent.runner.query_entry_adapter import QueryEntryAdapter
|
|
|
|
originals = input_files(query)
|
|
if not agent_input.attachments:
|
|
agent_input.attachments = [
|
|
InputAttachment.model_validate(item)
|
|
for item in QueryEntryAdapter._build_attachments(query, [c.model_dump() for c in agent_input.contents])
|
|
]
|
|
components = []
|
|
for i, attachment in enumerate(agent_input.attachments):
|
|
kind = (attachment.type or 'file').lower()
|
|
fields = {'url': attachment.url or '', 'base64': attachment.content or ''}
|
|
cls = {'image': pm.Image, 'voice': pm.Voice}.get(kind, pm.File)
|
|
if cls is pm.File:
|
|
fields.update(name=attachment.name or '', id=attachment.id or '')
|
|
component = cls(**fields)
|
|
# Match by payload, never by list position (contents and message chains
|
|
# can describe attachments in different orders).
|
|
for original in originals:
|
|
if type(original) is not cls:
|
|
continue
|
|
if (
|
|
(attachment.url and attachment.url == original.url)
|
|
or (attachment.content and attachment.content == original.base64)
|
|
or (
|
|
attachment.id
|
|
and attachment.id
|
|
== getattr(original, 'id', getattr(original, 'image_id', getattr(original, 'voice_id', None)))
|
|
)
|
|
or (
|
|
not attachment.url
|
|
and not attachment.content
|
|
and not attachment.id
|
|
and attachment.name
|
|
and attachment.name == getattr(original, 'name', None)
|
|
)
|
|
):
|
|
component = original
|
|
break
|
|
components.append(component)
|
|
attachment.ref, attachment.path = f'attachment-{i}', None
|
|
object.__setattr__(query, '_box_inputs', components)
|
|
|
|
|
|
def input_files(query):
|
|
prepared = getattr(query, '_box_inputs', None)
|
|
if prepared is not None:
|
|
return prepared
|
|
return [c for c in query.message_chain or [] if isinstance(c, (pm.Image, pm.Voice, pm.File))]
|
|
|
|
|
|
def binding_for(query) -> RunBoxBinding:
|
|
binding = getattr(query, '_box_binding', None)
|
|
if not isinstance(binding, RunBoxBinding):
|
|
raise BoxValidationError('Runner must bind a Box before using sandbox tools or files')
|
|
return binding
|
|
|
|
|
|
def exported_message(query, file_ids: list[str], *, consume: bool = False) -> pm.MessageChain:
|
|
binding = binding_for(query)
|
|
if not isinstance(file_ids, list) or len(file_ids) > 100 or any(type(x) is not str for x in file_ids):
|
|
raise BoxValidationError('file_ids must contain at most 100 exported file IDs')
|
|
if len(set(file_ids)) != len(file_ids):
|
|
raise BoxValidationError('Duplicate exported file ID')
|
|
if any(key not in binding.exported or key in binding.submitted for key in file_ids):
|
|
raise BoxValidationError('Output file does not belong to this run or has already been submitted')
|
|
components = []
|
|
for key in file_ids:
|
|
item = binding.exported[key]
|
|
if item['type'] == 'Image':
|
|
components.append(pm.Image(base64=item['base64']))
|
|
elif item['type'] == 'Voice':
|
|
components.append(pm.Voice(base64=item['base64']))
|
|
else:
|
|
components.append(pm.File(name=item['name'], base64=item['base64']))
|
|
if consume:
|
|
binding.submitted.update(file_ids)
|
|
return pm.MessageChain(components)
|
|
|
|
|
|
class RunnerBoxService:
|
|
def __init__(self, box):
|
|
self.box = box
|
|
|
|
async def status(self, context):
|
|
status = await self.box.get_status(context)
|
|
capacity = status.get('capacity', {})
|
|
return {
|
|
'enabled': self.box.enabled,
|
|
'available': status.get('available', False),
|
|
'limit': capacity.get('limit'),
|
|
'used': capacity.get('used'),
|
|
'remaining': capacity.get('remaining'),
|
|
'required_reuse_key': 'global' if self.box.managed_admission_required else None,
|
|
'reason': status.get('connector_error'),
|
|
}
|
|
|
|
async def sessions(self, context):
|
|
# Do not hide connection failures behind an empty list.
|
|
context = await self.box.require_workspace_sandbox(context)
|
|
return await self.box.client.get_sessions(action_context=self.box._action_context(context))
|
|
|
|
@staticmethod
|
|
def public_session(item):
|
|
return {'id': item['session_id'], 'status': item.get('status', 'idle')}
|
|
|
|
async def acquire(self, context, data, query=None):
|
|
request = BoxAcquireRequest.model_validate(data)
|
|
key = request.reuse_key
|
|
if self.box.managed_admission_required:
|
|
if key != 'global':
|
|
raise BoxValidationError('This deployment requires the global Box reuse key')
|
|
session_id = 'global'
|
|
else:
|
|
session_id = 'runner-' + hashlib.sha256(key.encode()).hexdigest()
|
|
# Plugin options cannot override policy, host mounts, or execution identity.
|
|
if set(request.options) - {'image'}:
|
|
raise BoxValidationError('Only the Box image can be specified; resource limits are Host-owned')
|
|
spec = {'session_id': session_id, **request.options}
|
|
from ..provider.tools.loaders.skill import build_execution_mounts
|
|
|
|
mounts = build_execution_mounts(self.box.ap, query) if query is not None else []
|
|
result = await self.box.create_session(context, spec, trusted_read_only_mounts=mounts)
|
|
return self.public_session(result)
|
|
|
|
async def bind(self, context, query, run_id, box_id):
|
|
current = getattr(query, '_box_binding', None)
|
|
if current is not None:
|
|
if current.run_id != run_id or current.session_id != box_id:
|
|
raise BoxValidationError('A run cannot change its Box after binding')
|
|
return self.binding_info(current)
|
|
sessions = await self.sessions(context)
|
|
item = next((item for item in sessions if item['session_id'] == box_id), None)
|
|
if item is None:
|
|
raise BoxValidationError('Box not found in the current Workspace')
|
|
current = getattr(query, '_box_binding', None)
|
|
if current is not None:
|
|
if current.run_id != run_id or current.session_id != box_id:
|
|
raise BoxValidationError('A run cannot change its Box after binding')
|
|
return self.binding_info(current)
|
|
fields = ('image', 'network', 'cpus', 'memory_mb', 'pids_limit', 'read_only_rootfs', 'persistent')
|
|
spec = {key: item[key] for key in fields if key in item}
|
|
from ..provider.tools.loaders.skill import build_execution_mounts
|
|
|
|
binding = RunBoxBinding(
|
|
run_id, box_id, spec, run_id, read_only_mounts=build_execution_mounts(self.box.ap, query)
|
|
)
|
|
object.__setattr__(query, '_box_binding', binding)
|
|
return self.binding_info(binding)
|
|
|
|
@staticmethod
|
|
def binding_info(binding):
|
|
return {
|
|
'box_id': binding.session_id,
|
|
'outbox': f'/workspace/outbox/{binding.io_scope}',
|
|
}
|
|
|
|
async def import_attachments(self, query, attachment_ids):
|
|
binding = binding_for(query)
|
|
available = {f'attachment-{i}': c for i, c in enumerate(input_files(query))}
|
|
ids = list(available) if attachment_ids is None else attachment_ids
|
|
if not isinstance(ids, list) or any(type(key) is not str or key not in available for key in ids):
|
|
raise BoxValidationError('Unknown input attachment reference')
|
|
if len(ids) != len(set(ids)):
|
|
raise BoxValidationError('Duplicate input attachment reference')
|
|
async with binding.lock:
|
|
for key in ids:
|
|
if key in binding.imported:
|
|
continue
|
|
transfer = copy.copy(query)
|
|
# Each input has its own directory: repeated imports cannot erase other files.
|
|
object.__setattr__(transfer, '_box_binding', replace(binding, io_scope=f'{binding.io_scope}-{key}'))
|
|
transfer.message_chain = pm.MessageChain([available[key]])
|
|
items = await self.box.materialize_inbound_attachments(transfer)
|
|
if not items:
|
|
raise BoxValidationError(f'Failed to import input attachment {key}')
|
|
binding.imported[key] = {'id': key, **items[0]}
|
|
return {'items': [binding.imported[key] for key in ids]}
|
|
|
|
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_selected_files(query, paths)
|
|
unseen = {}
|
|
for item in items:
|
|
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())
|
|
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 not in {'base64', '_outbox_path'}}
|
|
for key, item in binding.exported.items()
|
|
if key not in binding.submitted
|
|
],
|
|
}
|