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
+7 -1
View File
@@ -183,7 +183,12 @@ Box is allowed when no additional capacity remains.
A run binds one Box before native sandbox tools or file transfer. The Host does not
choose a conversation scope or stage attachments before starting the Runner. Input
references retain the original attachment metadata; import yields paths specific to
the run. Output export reads only that run's outbox and returns opaque file handles.
the run. Output export requires explicit normalized absolute sandbox file paths and returns
opaque handles scoped to that run. The selected files are validated as one batch
before registration; successful export removes only those files, and cleanup
failure is a warning with accepted handles retained. Writing an outbox file alone
does not deliver it. Core and LocalAgent currently pin SDK PR #180's exact commit
until a release includes the selected-path API.
`message.completed.file_ids` attaches explicitly exported files to Pipeline output;
Agents send them explicitly with `ctx.reply_files()`, subject to event reply permission.
The Pipeline wrapper never scans a Box. Binding ends with the run; the reusable Box
@@ -195,6 +200,7 @@ Box is the optional sandbox subsystem used by native execution tools, stdio MCP
In this repo:
- `pkg/box/service.py` is the application-facing facade for exec, sessions, managed processes, status, reconnects, quotas, generic mounts, and sandbox profiles.
- [Outbox export failures](docs/BOX_OUTBOX_EXPORT.md) documents bounded reads, permission fallback, and cleanup after Runner acceptance.
- `pkg/box/connector.py` connects to the Box Runtime over stdio, Windows subprocess+WebSocket, or remote WebSocket.
- `pkg/provider/tools/loaders/native.py` is the Core orchestration seam: the
Skill loader supplies generic read-only mounts to Box execution. `mcp_stdio.py`
+69
View File
@@ -0,0 +1,69 @@
# Explicit Box file export
A Runner calls `ctx.export_box_files(paths)` with a non-empty list of normalized
absolute paths inside its bound sandbox. The run's outbox is the recommended place
for deliverables. Writing a file alone does not deliver it; LocalAgent exposes an
`export_box_files` model tool and performs no final directory scan.
Core validates every selected file before registering any new handles. Missing,
unreadable, non-regular or symlinked paths, exceeded quotas and malformed runtime
responses fail the whole batch and retain its files. The selected-file reader runs
inside the existing Box with its original configuration and Workspace/generation
fencing. It opens directories and files without following symlinks and uses
nonblocking opens to avoid hanging on FIFOs.
The existing limits remain: 20 files and 50 MiB per call, 10 MiB per file, and
100 files / 50 MiB cumulatively per run. Raw SDK stdout is capped at 1 MiB per
stream including base64/JSON overhead, so transport may reject a batch below its
nominal file quotas. Only explicit SDK clipping notices are classified as
truncation; ordinary invalid JSON is not guessed to be a size failure. Quota
errors include the threshold, known actual value or lower bound, sandbox paths,
retention state and suggested action.
Successful acceptance returns opaque run-scoped handles and removes only selected
files whose content still matches the accepted bytes. Unselected files remain.
Cleanup failure is a warning on accepted handles; it must not cause duplicate
registration or delivery. Re-reading unchanged retained files reuses their IDs;
changed content or distinct source paths produces distinct handles. Already
submitted IDs are never returned for another delivery.
`message.completed.file_ids` attaches accepted files to automatic Pipeline output.
Explicit Agent replies use `ctx.reply_files()`. Platform-send failure after Core
acceptance terminates LocalAgent rather than regenerating or resending possibly
already-delivered files. Model export retries use ordinary paired tool-call
messages within the original tool/context/deadline budgets. There is no separate
two-attempt completion recovery loop. Streaming text already sent cannot be
retracted. Retention lasts only within the existing Box/run lifetime; expiry and
startup cleanup remain in effect.
## Coordinated dependency
This API requires the matching Core, LocalAgent and SDK implementation. The current
Core and LocalAgent uv locks pin SDK commit
`b300830b0e5339e7989be237efa811bedf761d55` from SDK PR #180. Published SDK 0.7.4 and
0.7.13 do not include the paths argument. LocalAgent rejects an older Runtime SDK
before preparing its file tool. Maintainers must coordinate the SDK release and
Runtime upgrade before release deployment.
The internal directory-collection helpers retain their strict bounded reads and
host-permission fallback; they do not define the explicit Runner export API.
## Regression checks
```bash
uv run --no-sync pytest tests/unit_tests/box -q
uv run --no-sync pytest tests/integration_tests/box/test_outbox_export.py -v -s
LANGBOT_LEGACY_PLUGIN_ROOT=/path/to/pinned/plugins uv run --no-sync pytest --required-suite local_agent -q
```
The Docker integration records uid/gid/mode, checks exact bytes from root-owned
0600 output using a non-root Core on Linux, verifies Box reuse and rejects
cross-run handle reuse. The required LocalAgent suite uses the integrity-checked
plugin source in `tests/fixtures/legacy_plugin_identity_sources.json`; Core,
Runtime, SQLite and Docker are real while the model and faults are scripted.
Cases cover selection, repair, streaming, tool-budget exhaustion, cleanup retry,
invalid response, timeout, real-ledger cancellation and no implicit export.
The opt-in `test_live_explicit_export.py` additionally needs dedicated
`BOX_LIVE_API_KEY` and `BOX_LIVE_MODEL` configuration. Its absence is a skip,
not evidence of a live-model acceptance run.
+4
View File
@@ -234,3 +234,7 @@ skip-magic-trailing-comma = false
# Like Black, automatically detect the appropriate line ending.
line-ending = "auto"
# Coordinated explicit-export API from langbot-plugin-sdk PR #180.
[tool.uv.sources]
langbot-plugin = { git = "https://github.com/langbot-app/langbot-plugin-sdk.git", rev = "b300830b0e5339e7989be237efa811bedf761d55" }
+123
View File
@@ -0,0 +1,123 @@
"""Bounded export diagnostics preserved by the SDK's text-only error relay."""
from __future__ import annotations
import json
import re
from langbot_plugin.api.entities.builtin.runner.errors import AgentAPIError
from langbot_plugin.box.errors import BoxValidationError
EXPORT_ERROR_PREFIX = 'LANGBOT_EXPORT_V1:'
SCRIPT_ERROR_PREFIX = 'LANGBOT_OUTBOX_LIMIT:'
SCRIPT_ERROR_EXIT_CODE = 73
LIMIT_LABELS = {
'file_bytes': 'per-file byte',
'collection_bytes': 'total-byte',
'file_count': 'file-count',
'depth': 'directory-depth',
'entries': 'directory-entry',
'directories': 'directory',
'run_files': 'per-run file-count',
'run_bytes': 'per-run byte',
}
class BoxExportError(BoxValidationError):
def __init__(self, error: AgentAPIError):
self.error = error
super().__init__(EXPORT_ERROR_PREFIX + error.model_dump_json())
def export_error(
kind: str,
*,
limit: int | None = None,
actual: int | None = None,
path: str = '',
lower_bound: bool = False,
remaining: int | None = None,
absolute_path: bool = False,
) -> BoxExportError:
if kind in LIMIT_LABELS:
code = 'box.export.limit_exceeded'
message = f'Attachment export failed: Sandbox outbox exceeds the {LIMIT_LABELS[kind]} limit'
action = 'Reduce or split the requested output within the indicated limit, then retry export.'
if kind in {'depth', 'entries', 'directories'}:
action = 'Flatten the outbox or remove unnecessary entries, preserving the requested deliverables.'
elif kind == 'transport_bytes':
code = 'box.export.transport_truncated'
message = 'Attachment export failed: the SDK explicitly reported truncated export output'
action = 'Reduce or split attachments so their base64/JSON response fits the transport limit.'
elif kind == 'invalid_response':
code = 'box.export.invalid_response'
message = 'Attachment export failed: Sandbox outbox returned invalid attachment data'
action = 'Inspect the sandbox/runtime response; do not assume a size limit caused this failure.'
else:
raise ValueError(f'Unknown export failure kind: {kind}')
return BoxExportError(
AgentAPIError(
code=code,
message=message,
retryable=kind != 'invalid_response',
details={
'version': 1,
'phase': 'export',
'limit_type': kind,
'limit': limit,
'actual': actual,
'actual_is_lower_bound': lower_bound,
'remaining': remaining,
'relative_paths': [path] if path and not absolute_path else [],
'sandbox_paths': [path] if path and absolute_path else [],
'files_retained': True,
'suggested_action': action,
},
)
)
def script_export_error(
stderr: str, exit_code: int, limits: dict[str, int], *, selected_paths: list[str] | None = None
) -> BoxExportError | None:
if exit_code != SCRIPT_ERROR_EXIT_CODE or len(stderr) > 8192:
return None
line = stderr.strip().splitlines()[-1] if stderr.strip() else ''
if not line.startswith(SCRIPT_ERROR_PREFIX):
return None
try:
value = json.loads(line.removeprefix(SCRIPT_ERROR_PREFIX))
kind, limit, actual, path = (value[k] for k in ('kind', 'limit', 'actual', 'path'))
if (
kind not in limits
or type(limit) is not int
or limit != limits[kind]
or type(actual) is not int
or actual <= limit
or not isinstance(path, str)
or len(path) > 2400
or (selected_paths is None and path.startswith('/'))
or (selected_paths is not None and path not in selected_paths)
or '\\' in path
or '\x00' in path
or '..' in path.split('/')
):
return None
return export_error(
kind, limit=limit, actual=actual, path=path, lower_bound=True, absolute_path=selected_paths is not None
)
except (ValueError, TypeError, KeyError):
return None
def transport_export_error(stdout: str) -> BoxExportError | None:
clipped = re.search(r'\n\.\.\. \[raw output clipped at (\d+) bytes, (\d+) bytes discarded\]$', stdout)
if clipped:
limit, discarded = map(int, clipped.groups())
if limit > 0 and discarded > 0:
return export_error('transport_bytes', limit=limit, actual=limit + discarded)
clipped = re.search(r'\n\.\.\. \[output clipped at (\d+) bytes\]$', stdout)
if clipped and int(clipped[1]) > 0:
return export_error('transport_bytes', limit=int(clipped[1]))
return None
+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
]
],
}
+20 -10
View File
@@ -23,12 +23,18 @@ def outbox_script(
'max_total_bytes': max_total_bytes,
}
return f'settings = {settings!r}\n' + textwrap.dedent("""\
import base64, errno, json, os, stat
import base64, errno, json, os, stat, sys
directory_flags = os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW
file_flags = os.O_RDONLY | os.O_NOFOLLOW | os.O_NONBLOCK
out = []
budget = {'entries': 512, 'directories': 64, 'bytes': 0}
def reject(kind, limit, actual, path):
failure = {'kind': kind, 'limit': limit, 'actual': actual, 'path': path}
print('Sandbox outbox exceeds the ' + kind.replace('_', '-') + ' limit', file=sys.stderr)
print('LANGBOT_OUTBOX_LIMIT:' + json.dumps(failure), file=sys.stderr)
raise SystemExit(73)
def component(value):
if (not value or value in {'.', '..'} or len(os.fsencode(value)) > 240
or any(c in value for c in ('/', '\\\\', '\\x00'))):
@@ -37,13 +43,11 @@ def outbox_script(
def read_directory(fd, prefix='', depth=0):
if depth > 8:
return
reject('depth', 8, depth, prefix)
with os.scandir(fd) as entries:
for entry in entries:
if len(out) >= settings['max_files']:
return
if budget['entries'] <= 0:
raise RuntimeError('Sandbox outbox exceeds the directory-entry limit')
reject('entries', 512, 513, prefix)
budget['entries'] -= 1
name = component(entry.name)
relative = prefix + '/' + name if prefix else name
@@ -51,7 +55,7 @@ def outbox_script(
continue
if entry.is_dir(follow_symlinks=False):
if budget['directories'] <= 0:
raise RuntimeError('Sandbox outbox exceeds the directory limit')
reject('directories', 64, 65, relative)
budget['directories'] -= 1
flags = directory_flags
elif entry.is_file(follow_symlinks=False):
@@ -71,13 +75,19 @@ def outbox_script(
continue
if not stat.S_ISREG(metadata.st_mode):
continue
if len(out) >= settings['max_files']:
reject('file_count', settings['max_files'], len(out) + 1, relative)
remaining = settings['max_total_bytes'] - budget['bytes']
if metadata.st_size > settings['max_file_bytes'] or metadata.st_size > remaining:
raise RuntimeError('Sandbox output attachment exceeds the export size limit')
if metadata.st_size > settings['max_file_bytes']:
reject('file_bytes', settings['max_file_bytes'], metadata.st_size, relative)
if metadata.st_size > remaining:
reject('collection_bytes', settings['max_total_bytes'], budget['bytes'] + metadata.st_size, relative)
with os.fdopen(child_fd, 'rb', closefd=False) as source:
data = source.read(settings['max_file_bytes'] + 1)
if len(data) > settings['max_file_bytes'] or len(data) > remaining:
raise RuntimeError('Sandbox output attachment exceeds the export size limit')
if len(data) > settings['max_file_bytes']:
reject('file_bytes', settings['max_file_bytes'], len(data), relative)
if len(data) > remaining:
reject('collection_bytes', settings['max_total_bytes'], budget['bytes'] + len(data), relative)
out.append({'name': relative, 'b64': base64.b64encode(data).decode('ascii')})
budget['bytes'] += len(data)
finally:
+50 -26
View File
@@ -11,6 +11,15 @@ class UnsafeWorkspacePathError(OSError):
"""A tenant-controlled path could not be opened without following links."""
class WorkspaceReadLimitError(ValueError):
"""The outbox cannot be read completely within the export limits."""
def __init__(self, kind: str, limit: int, actual: int, path: str = '', *, lower_bound: bool = False):
self.kind, self.limit, self.actual, self.path = kind, limit, actual, path
self.lower_bound = lower_bound
super().__init__(f'Sandbox outbox exceeds the {kind} limit')
_DIRECTORY_FLAGS = (
os.O_RDONLY | getattr(os, 'O_DIRECTORY', 0) | getattr(os, 'O_NOFOLLOW', 0) | getattr(os, 'O_CLOEXEC', 0)
)
@@ -185,16 +194,16 @@ def _read_directory(
total: list[int],
remaining_entries: list[int],
remaining_directories: list[int],
max_entries: int,
max_directories: int,
depth: int,
) -> None:
if depth > 8:
return
raise WorkspaceReadLimitError('depth', 8, depth, prefix, lower_bound=True)
with os.scandir(directory_fd) as iterator:
for entry in iterator:
if len(output) >= max_files or total[0] >= max_total_bytes:
return
if remaining_entries[0] <= 0:
raise UnsafeWorkspacePathError('Sandbox outbox exceeds the directory-entry limit')
raise WorkspaceReadLimitError('entries', max_entries, max_entries + 1, prefix, lower_bound=True)
remaining_entries[0] -= 1
name = _component(entry.name)
relative = f'{prefix}/{name}' if prefix else name
@@ -202,14 +211,11 @@ def _read_directory(
continue
if entry.is_dir(follow_symlinks=False):
if remaining_directories[0] <= 0:
raise UnsafeWorkspacePathError('Sandbox outbox exceeds the directory limit')
raise WorkspaceReadLimitError(
'directories', max_directories, max_directories + 1, relative, lower_bound=True
)
remaining_directories[0] -= 1
try:
child_fd = os.open(name, _DIRECTORY_FLAGS, dir_fd=directory_fd)
except PermissionError:
raise
except OSError:
continue
child_fd = os.open(name, _DIRECTORY_FLAGS, dir_fd=directory_fd)
try:
_read_directory(
child_fd,
@@ -221,28 +227,37 @@ def _read_directory(
total=total,
remaining_entries=remaining_entries,
remaining_directories=remaining_directories,
max_entries=max_entries,
max_directories=max_directories,
depth=depth + 1,
)
finally:
os.close(child_fd)
continue
try:
file_fd = os.open(name, _FILE_READ_FLAGS, dir_fd=directory_fd)
except PermissionError:
raise
except OSError:
if not entry.is_file(follow_symlinks=False):
continue
file_fd = os.open(name, _FILE_READ_FLAGS, dir_fd=directory_fd)
try:
metadata = os.fstat(file_fd)
if not stat.S_ISREG(metadata.st_mode) or metadata.st_size > max_file_bytes:
if not stat.S_ISREG(metadata.st_mode):
continue
if len(output) >= max_files:
raise WorkspaceReadLimitError('file_count', max_files, len(output) + 1, relative, lower_bound=True)
if metadata.st_size > max_file_bytes:
raise WorkspaceReadLimitError('file_bytes', max_file_bytes, metadata.st_size, relative)
remaining = max_total_bytes - total[0]
if metadata.st_size > remaining:
continue
raise WorkspaceReadLimitError(
'collection_bytes', max_total_bytes, total[0] + metadata.st_size, relative, lower_bound=True
)
with os.fdopen(file_fd, 'rb', closefd=False) as file_obj:
data = file_obj.read(max_file_bytes + 1)
if len(data) > max_file_bytes or len(data) > remaining:
continue
if len(data) > max_file_bytes:
raise WorkspaceReadLimitError('file_bytes', max_file_bytes, len(data), relative, lower_bound=True)
if len(data) > remaining:
raise WorkspaceReadLimitError(
'collection_bytes', max_total_bytes, total[0] + len(data), relative, lower_bound=True
)
output.append((relative, data))
total[0] += len(data)
finally:
@@ -260,7 +275,11 @@ def read_regular_files(
max_entries: int = 512,
max_directories: int = 64,
) -> list[tuple[str, bytes]]:
"""Read bounded regular files without following tenant-created links."""
"""Read regular files completely within bounds, without following links.
Missing outboxes are empty. Unreadable files/directories and exhausted
limits raise, so a caller cannot mistake a partial read for a complete export.
"""
output: list[tuple[str, bytes]] = []
try:
@@ -275,13 +294,18 @@ def read_regular_files(
total=[0],
remaining_entries=[max_entries],
remaining_directories=[max_directories],
max_entries=max_entries,
max_directories=max_directories,
depth=0,
)
except (FileNotFoundError, UnsafeWorkspacePathError):
# A missing directory is an empty outbox. An unsafe existing path is
# deliberately surfaced to the caller rather than followed.
if os.path.lexists(os.path.join(root, subdir, query_key)):
raise
except UnsafeWorkspacePathError as exc:
# Only failure to open a missing outbox is empty. A file disappearing
# during traversal, or a permission failure, is an incomplete read.
if isinstance(exc.__cause__, FileNotFoundError):
return []
if isinstance(exc.__cause__, PermissionError):
raise exc.__cause__ from None
raise
return output
+52
View File
@@ -0,0 +1,52 @@
"""Explicit file selection inside a sandbox, with no symlink traversal."""
import textwrap
def selected_files_script(paths, *, max_file_bytes, max_total_bytes, cleanup=None):
settings = dict(paths=paths, max_file_bytes=max_file_bytes, max_total_bytes=max_total_bytes, cleanup=cleanup)
return f'settings = {settings!r}\n' + textwrap.dedent("""\
import os, stat, json, base64, hashlib, sys
flags = os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW
def reject(kind, limit, actual, path):
print('LANGBOT_OUTBOX_LIMIT:' + json.dumps(dict(kind=kind, limit=limit, actual=actual, path=path)), file=sys.stderr)
raise SystemExit(73)
result = []
total = 0
for path in settings['paths']:
parts = path.split('/')
if not path.startswith('/') or any(p in ('', '.', '..') for p in parts[1:]):
raise ValueError('Expected a normalized absolute sandbox file path: ' + path)
fd = os.open('/', flags)
try:
for part in parts[1:-1]:
child = os.open(part, flags, dir_fd=fd)
os.close(fd)
fd = child
file_fd = os.open(parts[-1], os.O_RDONLY | os.O_NOFOLLOW | os.O_NONBLOCK, dir_fd=fd)
with os.fdopen(file_fd, 'rb') as source:
metadata = os.fstat(source.fileno())
if not stat.S_ISREG(metadata.st_mode):
raise ValueError('Not a regular file: ' + path)
if metadata.st_size > settings['max_file_bytes']:
reject('file_bytes', settings['max_file_bytes'], metadata.st_size, path)
data = source.read(settings['max_file_bytes'] + 1)
total += len(data)
if len(data) > settings['max_file_bytes']:
reject('file_bytes', settings['max_file_bytes'], len(data), path)
if total > settings['max_total_bytes']:
reject('collection_bytes', settings['max_total_bytes'], total, path)
digest = hashlib.sha256(data).hexdigest()
if settings['cleanup'] is not None:
current = os.stat(parts[-1], dir_fd=fd, follow_symlinks=False)
if (current.st_dev, current.st_ino) == (metadata.st_dev, metadata.st_ino) and digest == settings['cleanup'].get(path):
os.unlink(parts[-1], dir_fd=fd)
else:
raise ValueError('File changed after acceptance; retained: ' + path)
else:
result.append({'name': path, 'b64': base64.b64encode(data).decode('ascii')})
finally:
os.close(fd)
print(json.dumps(result))
""")
+132 -41
View File
@@ -20,6 +20,7 @@ from langbot_plugin.box.security import BOX_SHARED_WORKSPACE_PROBE_PREFIX
from .admission import SandboxAdmissionController, require_cloud_admission_policy
from .connector import BoxRuntimeConnector, _get_box_config
from . import secure_fs
from .export_errors import export_error, script_export_error, transport_export_error
from ..utils import httpclient
from ..api.http.context import ExecutionContext
from ..api.http.service.tenant import TenantContext, require_workspace_uuid
@@ -1126,41 +1127,53 @@ class BoxService:
)
return result
async def collect_outbound_attachments(self, query: pipeline_query.Query) -> list[dict]:
async def collect_outbound_attachments(self, query: pipeline_query.Query, *, clear: bool = True) -> list[dict]:
"""Collect files the agent produced in the sandbox outbox.
Read regular files within the configured attachment limits from the
current run's outbox. Private container output uses the bound sandbox
channel; invalid or truncated responses retain the files. Clear a
successful collection and return ``{type, name, base64}`` descriptors.
Read and traversal limits fail without clearing output. Runner export
defers cleanup until its cumulative run limits have also been checked.
"""
if not self._available:
if not clear:
raise BoxError('Box runtime is not available; output files were retained')
return []
if self._cloud_managed:
await self.require_workspace_sandbox(self._query_execution_context(query))
host_dir = self._host_query_dir(self.OUTBOX_SUBDIR, query)
if host_dir is not None:
try:
entries = await asyncio.to_thread(self._read_outbox_host, host_dir)
except PermissionError:
# Root-owned container output can be private to its sandbox UID.
try:
if host_dir is not None:
try:
entries = await asyncio.to_thread(self._read_outbox_host, host_dir)
except PermissionError:
# Root-owned container output can be unreadable to Core.
# Retry the complete read inside the already-bound Box.
entries = await self._read_outbox_via_exec(query)
else:
entries = await self._read_outbox_via_exec(query)
else:
entries = await self._read_outbox_via_exec(query)
except OSError as exc:
raise BoxError('Failed to read sandbox outbox; output files were retained') from exc
attachments = self._classify_outbound_entries(entries)
attachments = self._classify_outbound_entries(entries, include_source_path=not clear)
# Always clear the per-query outbox after reading — even when nothing
# was collected — so a later turn that reuses the same query_id (the
# counter resets across restarts) never inherits stale files.
await self._clear_outbox(query, host_dir)
# A successful empty read still clears stale state, but an incomplete
# read must never reach cleanup. Export can be retried after the failure.
if clear:
await self._clear_outbox(query, host_dir)
if attachments:
self.ap.logger.info(
f'Collected {len(attachments)} outbound attachment(s) from sandbox: query_id={query.query_id}'
)
return attachments
async def clear_outbound_attachments(self, query: pipeline_query.Query) -> None:
"""Clear output after a complete read has been accepted by the Runner."""
await self._clear_outbox(query, self._host_query_dir(self.OUTBOX_SUBDIR, query))
def _read_outbox_host(self, host_dir: str) -> list[dict]:
"""Read outbox files straight off the bind-mounted host directory."""
import base64 as _b64
@@ -1179,53 +1192,128 @@ class BoxService:
)
except secure_fs.UnsafeWorkspacePathError as exc:
raise BoxValidationError('Sandbox outbox contains an unsafe symbolic link') from exc
except secure_fs.WorkspaceReadLimitError as exc:
raise export_error(
exc.kind, limit=exc.limit, actual=exc.actual, path=exc.path, lower_bound=exc.lower_bound
) from exc
return [{'name': name, 'b64': _b64.b64encode(data).decode('ascii')} for name, data in files]
async def _read_outbox_via_exec(self, query: pipeline_query.Query) -> list[dict]:
async def _read_outbox_via_exec(self, query: pipeline_query.Query, paths=None) -> list[dict]:
"""Fallback: read the outbox over the exec channel (E2B / remote).
Use the same Workspace-scoped execution path as tools, retaining raw
stdout because base64 payloads can exceed the display truncation limit.
"""
import json as _json
import base64
import binascii
from .sandbox_files import outbox_script
max_file_bytes = self._ATTACHMENT_MAX_BYTES
max_files = self._ATTACHMENT_MAX_FILES
max_total_bytes = self._ATTACHMENT_MAX_TOTAL_BYTES
script = outbox_script(
self.OUTBOX_MOUNT_DIR,
self._attachment_query_key(query),
operation='read',
max_file_bytes=max_file_bytes,
max_files=self._ATTACHMENT_MAX_FILES,
max_files=max_files,
max_total_bytes=max_total_bytes,
)
if paths is not None:
from .selected_files import selected_files_script
script = selected_files_script(paths, max_file_bytes=max_file_bytes, max_total_bytes=max_total_bytes)
spec_payload: dict = {
'cmd': f"python3 - <<'LBPY'\n{script}\nLBPY",
'timeout_sec': 120,
'session_id': self.resolve_box_session_id(query),
}
result = await self._execute_spec_payload_result(spec_payload, query)
if not result.ok:
if 'Sandbox output attachment exceeds the export size limit' in (result.stderr or ''):
raise BoxValidationError(
f'Sandbox output attachments exceed the export limit '
f'({max_file_bytes} bytes per file, {max_total_bytes} bytes total); files were retained'
)
raise BoxValidationError(f'Failed to read sandbox outbox (exit code {result.exit_code})')
try:
entries = _json.loads(str(result.stdout or '').strip().splitlines()[-1])
if not isinstance(entries, list) or any(
not isinstance(entry, dict)
or not isinstance(entry.get('name'), str)
or not isinstance(entry.get('b64'), str)
for entry in entries
):
raise ValueError('Invalid attachment list')
return entries
except (ValueError, IndexError) as exc:
raise BoxValidationError('Sandbox outbox returned invalid attachment data') from exc
result = await self._execute_spec_payload_result(spec_payload, query)
except BoxError:
raise
except Exception as exc:
raise BoxError('Failed to read sandbox outbox; output files were retained') from exc
if not result.ok:
failure = script_export_error(
result.stderr or '',
result.exit_code,
{
'file_bytes': max_file_bytes,
'collection_bytes': max_total_bytes,
'file_count': max_files,
'depth': 8,
'entries': 512,
'directories': 64,
},
selected_paths=paths,
)
if failure is not None:
raise failure
raise BoxValidationError(
f'Failed to read sandbox outbox or selected files (exit code {result.exit_code}); '
f'output files were retained: {(result.stderr or "")[-4000:]}'
)
truncated = transport_export_error(result.stdout)
if truncated is not None:
raise truncated
try:
entries = json.loads(result.stdout)
if not isinstance(entries, list) or len(entries) > max_files:
raise ValueError('Invalid file list')
total_bytes = 0
for entry in entries:
if not isinstance(entry, dict) or not isinstance(entry.get('name'), str):
raise ValueError('Invalid file entry')
if paths is None and entry['name'].startswith('/'):
raise ValueError('Expected a relative outbox path')
if paths is not None and entry['name'] not in paths:
raise ValueError('Unexpected exported path')
for component in entry['name'].lstrip('/').split('/'):
secure_fs._component(component)
encoded = entry.get('b64')
if not isinstance(encoded, str) or len(encoded) > 4 * ((max_file_bytes + 2) // 3):
raise ValueError('Invalid file payload')
size = len(base64.b64decode(encoded, validate=True))
total_bytes += size
if size > max_file_bytes or total_bytes > max_total_bytes:
raise ValueError('File payload exceeds export limits')
except (TypeError, ValueError, OSError, binascii.Error) as exc:
raise export_error('invalid_response') from exc
return entries
async def collect_selected_files(self, query, paths):
if not self._available:
raise BoxError('Box runtime is not available')
entries = await self._read_outbox_via_exec(query, paths)
if len(entries) != len(paths) or {e['name'] for e in entries} != set(paths):
raise BoxValidationError('Incomplete selected file export; files were retained')
return self._classify_outbound_entries(entries, include_source_path=True)
async def clear_selected_files(self, query, items):
import base64
import hashlib
from .selected_files import selected_files_script
digests = {
item['_outbox_path']: hashlib.sha256(base64.b64decode(item['base64'].split(';base64,')[-1])).hexdigest()
for item in items
}
script = selected_files_script(
list(digests),
max_file_bytes=self._ATTACHMENT_MAX_BYTES,
max_total_bytes=self._ATTACHMENT_MAX_TOTAL_BYTES,
cleanup=digests,
)
result = await self._execute_spec_payload_result(
{
'cmd': f"python3 - <<'LBPY'\n{script}\nLBPY",
'timeout_sec': 120,
'session_id': self.resolve_box_session_id(query),
},
query,
)
return None if result.ok else 'Files accepted, but cleanup failed; do not repeat delivery.'
async def _clear_outbox(self, query: pipeline_query.Query, host_dir: str | None) -> None:
"""Empty the per-query outbox after collection.
@@ -1285,7 +1373,7 @@ class BoxService:
self.ap.logger.warning(f'Failed to clear sandbox outbox {target_dir}: {exc}')
@staticmethod
def _classify_outbound_entries(entries: list[dict]) -> list[dict]:
def _classify_outbound_entries(entries: list[dict], *, include_source_path: bool = False) -> list[dict]:
"""Classify outbox files into Image/Voice/File component descriptors."""
image_exts = {'png', 'jpg', 'jpeg', 'gif', 'webp', 'bmp'}
voice_exts = {'wav', 'mp3', 'silk', 'amr', 'ogg', 'm4a', 'aac'}
@@ -1301,17 +1389,20 @@ class BoxService:
for entry in entries or []:
name = str(entry.get('name', '') or '')
b64 = entry.get('b64')
if not name or not b64:
if not name or not isinstance(b64, str):
continue
ext = name.rsplit('.', 1)[-1].lower() if '.' in name else ''
base_name = os.path.basename(name)
if ext in image_exts:
mime = mime_by_ext.get(ext, 'image/png')
attachments.append({'type': 'Image', 'name': base_name, 'base64': f'data:{mime};base64,{b64}'})
item = {'type': 'Image', 'name': base_name, 'base64': f'data:{mime};base64,{b64}'}
elif ext in voice_exts:
attachments.append({'type': 'Voice', 'name': base_name, 'base64': f'data:audio/{ext};base64,{b64}'})
item = {'type': 'Voice', 'name': base_name, 'base64': f'data:audio/{ext};base64,{b64}'}
else:
attachments.append({'type': 'File', 'name': base_name, 'base64': b64})
item = {'type': 'File', 'name': base_name, 'base64': b64}
if include_source_path:
item['_outbox_path'] = name
attachments.append(item)
return attachments
async def shutdown(self):
+4 -1
View File
@@ -3,6 +3,7 @@
from langbot_plugin.entities.io.actions.enums import PluginToRuntimeAction as Action
from langbot_plugin.runtime.io.handler import ActionResponse
from ..box.runner import RunnerBoxService
from ..box.export_errors import BoxExportError
from .agent_run_support import _validate_agent_run_session
@@ -45,7 +46,7 @@ def register(h):
if action == Action.IMPORT_BOX_ATTACHMENTS:
return ActionResponse.success(await service.import_attachments(query, data.get('attachment_ids')))
if action == Action.EXPORT_BOX_FILES:
return ActionResponse.success(await service.export_files(query))
return ActionResponse.success(await service.export_files(query, data.get('paths')))
raise ValueError('Unsupported Box action')
def register_action(action):
@@ -53,6 +54,8 @@ def register(h):
async def invoke(data):
try:
return await dispatch(action, data)
except BoxExportError as exc:
return ActionResponse.error(str(exc), data={'error': exc.error.model_dump(mode='json')})
except Exception as exc:
return ActionResponse.error(f'{type(exc).__name__}: {exc}')
+214 -13
View File
@@ -554,7 +554,14 @@ def _run_local_agent_probe(tmpdir: Path, probe):
platform_utils.standalone_runtime = previous_standalone_runtime
os.chdir(previous_cwd)
return asyncio.run(_run())
# Source installs resolve data from the checkout, not the current working
# directory. Scope the override to this probe, without leaking to other E2Es.
with pytest.MonkeyPatch.context() as patch:
patch.setenv('LANGBOT_DATA_ROOT', str(tmpdir / 'data'))
# Do not replace pytest-asyncio's policy loop when this synchronous
# probe follows async integration tests in the same pytest process.
with asyncio.Runner(loop_factory=asyncio.new_event_loop) as runner:
return runner.run(_run())
@pytest.mark.contract('agent_ledger')
@@ -1022,15 +1029,24 @@ def test_local_runner_combines_rag_compaction_and_multi_turn_tool_loop(
conn.close()
@pytest.mark.parametrize(
'scenario',
['normal', 'repair', 'streaming', 'tool_limit', 'cleanup', 'invalid', 'timeout', 'cancelled', 'no_export'],
)
@pytest.mark.contract('agent_box')
def test_local_runner_owns_box_reuse_and_explicit_files(
local_agent_e2e_tmpdir,
local_agent_e2e_config_path,
local_agent_runtime_process,
capfd,
scenario,
):
"""Real plugin RPC and Docker sandbox, with only the model scripted."""
del local_agent_e2e_config_path, local_agent_runtime_process
import base64
from unittest.mock import AsyncMock
repair_export = scenario in {'repair', 'streaming', 'tool_limit', 'cancelled'}
from langbot_plugin.box.backend import DockerBackend
from langbot_plugin.box.runtime import BoxRuntime
from langbot_plugin.api.entities.builtin.runner.input import InputAttachment
@@ -1062,6 +1078,8 @@ def test_local_runner_owns_box_reuse_and_explicit_files(
self.box = box
self.bindings = []
self.outboxes = []
self.calls_by_run = {}
self.queries = []
async def get_resolved_tool_catalog(self, *args, **kwargs):
return [{'name': 'exec', 'source': 'native', 'source_id': None}]
@@ -1074,21 +1092,26 @@ def test_local_runner_owns_box_reuse_and_explicit_files(
}
async def execute_func_call(self, name, parameters, query=None, source_ref=None):
self.queries.append(query)
binding = binding_for(query)
self.bindings.append((binding.session_id, binding.run_id))
source = next(iter(binding.imported.values()))['path']
outbox = f'/workspace/outbox/{binding.io_scope}'
self.outboxes.append(outbox)
self.calls_by_run[binding.run_id] = self.calls_by_run.get(binding.run_id, 0) + 1
# Shell arguments are Host-issued paths, never model input.
import shlex
directory_mode = '-m 700 ' if len(self.bindings) == 2 else ''
directory_mode = '-m 700 ' if len(self.calls_by_run) == 2 else ''
extra = f' && printf KEEP > {shlex.quote(outbox + "/keep.txt")}'
if repair_export and self.calls_by_run[binding.run_id] == 1:
extra += f' && printf 1234567890123456789012345 > {shlex.quote(outbox + "/answer.txt")}'
result = await self.box.execute_tool(
{
'command': (
f'mkdir {directory_mode}-p {shlex.quote(outbox)} && '
f'cp {shlex.quote(source)} {shlex.quote(outbox + "/answer.txt")} && '
f"stat -c '%u %a' {shlex.quote(outbox)} {shlex.quote(outbox + '/answer.txt')}"
f"stat -c '%u %a' {shlex.quote(outbox)} {shlex.quote(outbox + '/answer.txt')}" + extra
)
},
query,
@@ -1096,8 +1119,10 @@ def test_local_runner_owns_box_reuse_and_explicit_files(
assert result['ok'], result
ownership = result['stdout'].strip().splitlines()
assert ownership[-1] == '0 600', ownership
if len(self.bindings) == 2:
if len(self.calls_by_run) == 2:
assert ownership[0] == '0 700', ownership
with capfd.disabled():
print(f'Core uid={os.geteuid()}; LocalAgent Docker ownership: {ownership}', flush=True)
return result
async def probe(ap):
@@ -1117,13 +1142,101 @@ def test_local_runner_owns_box_reuse_and_explicit_files(
box = BoxService(ap, client=Client(ap.logger, runtime))
ap.box_service = box
await box.initialize()
if repair_export:
box._ATTACHMENT_MAX_BYTES = 8
collect = box.collect_selected_files
export_calls = []
async def observed_collect(query, paths):
export_calls.append(query._box_binding.run_id)
if scenario == 'cancelled' and export_calls.count(query._box_binding.run_id) == 2:
from langbot.pkg.agent.runner.run_ledger_store import RunLedgerStore
row = await RunLedgerStore(ap.persistence_mgr.get_db_engine()).request_cancel(
run_id=query._box_binding.run_id,
status_reason='cancel during export recovery',
)
assert row is not None
await asyncio.sleep(10)
raise RuntimeError('Cancelled export must not resume generation')
if scenario == 'invalid':
from langbot.pkg.box.export_errors import export_error
raise export_error('invalid_response')
if scenario == 'timeout':
await asyncio.sleep(6)
raise TimeoutError('Injected stalled export')
return await collect(query, paths)
box.collect_selected_files = observed_collect
if scenario == 'cleanup':
# A failed best-effort cleanup leaves successfully accepted files.
box.clear_selected_files = AsyncMock(
return_value='Files accepted, but cleanup failed; do not repeat delivery.'
)
manager = ToolManager(box)
ap.tool_mgr = manager
fake = await _inject_fake_llm_model(ap)
if scenario == 'streaming':
from langbot_plugin.api.entities.builtin.provider.message import MessageChunk
async def scripted_stream(*args, **kwargs):
message = await fake.invoke_llm(*args, **kwargs)
yield MessageChunk(
role='assistant', content=message.content, tool_calls=message.tool_calls, is_final=True
)
fake.invoke_llm_stream = scripted_stream
context = await ap.plugin_connector._current_execution_context()
async def assert_retained():
inspection = await box.execute_in_context(
context,
{
'cmd': f'test -f {shlex.quote(manager.outboxes[-1] + "/answer.txt")}',
'session_id': manager.bindings[-1][0],
},
)
assert inspection.ok, inspection
try:
for index in range(2):
fake.queue_llm_responses(_scripted_tool_call('exec'), 'File is ready.')
def export_call(**kwargs):
from langbot_plugin.api.entities.builtin.provider.message import FunctionCall, Message, ToolCall
return Message(
role='assistant',
content='',
tool_calls=[
ToolCall(
id=f'export-{len(export_calls)}',
type='function',
function=FunctionCall(
name='export_box_files',
arguments=json.dumps(
{
'paths': [manager.outboxes[-1] + '/answer.txt'],
}
),
),
)
],
)
fake.queue_llm_responses(_scripted_tool_call('exec'))
if scenario == 'no_export':
fake.queue_llm_responses('Created the file without exporting it.')
else:
fake.queue_llm_responses(export_call)
if scenario in {'repair', 'streaming', 'cancelled'}:
fake.queue_llm_responses(_scripted_tool_call('exec', call_id='repair'), export_call)
elif scenario in {'cleanup', 'tool_limit'}:
fake.queue_llm_responses(export_call)
if scenario == 'tool_limit':
fake.queue_llm_responses(export_call)
elif scenario not in {'invalid', 'timeout', 'cancelled'}:
fake.queue_llm_responses('File accepted.')
event = _event(
event_id=f'box-event-{index}',
conversation_id=f'box-conversation-{index}',
@@ -1140,20 +1253,108 @@ def test_local_runner_owns_box_reuse_and_explicit_files(
runner_config={'box-enabled': True, 'box-session-id-template': '{global}'},
)
binding.processor_type = 'pipeline'
messages = await _run_runner(ap, event, binding)
if scenario == 'streaming':
event.delivery.supports_streaming = True
binding.delivery_policy.enable_streaming = True
if scenario == 'timeout':
binding.runner_config['timeout'] = 5
if scenario == 'tool_limit':
binding.runner_config['max-tool-iterations'] = 3
events = []
async def observe(result):
events.append(result)
if scenario == 'cleanup' and result['type'] == 'run.completed':
query = manager.queries[-1]
assert len(query._box_binding.exported) == 1
assert len(query._box_binding.submitted) == 1
from langbot.pkg.agent.runner.errors import RunnerExecutionError
messages = []
failure = None
try:
async for message in ap.agent_run_orchestrator.run(
event,
binding,
adapter_context={'_execution_context': context, '_result_observer': observe},
):
messages.append(message)
except RunnerExecutionError as exc:
failure = exc
terminals = [e for e in events if e['type'] in {'run.completed', 'run.failed'}]
if scenario == 'timeout':
assert failure is not None and 'timed out' in str(failure)
assert not any(e['type'] in {'run.completed', 'message.completed'} for e in events)
assert not any(message.attachments for message in messages)
await assert_retained()
continue
assert len(terminals) == 1, events
if scenario in {'invalid', 'cancelled'}:
assert failure is not None
assert terminals[0]['type'] == 'run.failed', events
assert not any(e['type'] == 'message.completed' for e in events), events
assert not any(message.attachments for message in messages), messages
if scenario == 'cancelled':
assert terminals[0]['data']['code'] == 'cancelled'
else:
assert 'invalid attachment data' in terminals[0]['data']['error']
await assert_retained()
continue
assert failure is None, failure
assert terminals[0]['type'] == 'run.completed', events
assert sum(e['type'] == 'message.completed' for e in events) == 1
if scenario == 'streaming':
deltas = [e for e in events if e['type'] == 'message.delta']
assert deltas and all(not e['data'].get('file_ids') for e in deltas)
output = [component for message in messages for component in (message.attachments or [])]
assert len(output) == 1, messages
assert base64.b64decode(output[0].base64) == f'run-{index}'.encode()
if scenario in {'tool_limit', 'no_export'}:
assert not output, messages
await assert_retained()
if scenario == 'tool_limit':
assert any('limit' in _message_text(message).lower() for message in messages)
else:
assert len(output) == 1, messages
assert base64.b64decode(output[0].base64) == f'run-{index}'.encode()
inspection = await box.execute_in_context(
context,
{'cmd': f'ls -A {shlex.quote(manager.outboxes[-1])}', 'session_id': manager.bindings[-1][0]},
{
'cmd': f'ls -A {shlex.quote(manager.outboxes[-1])}',
'session_id': manager.bindings[-1][0],
},
)
assert inspection.exit_code == 0, inspection
assert inspection.stdout.strip() == '', inspection.stdout
assert inspection.ok, inspection
expected_files = {'keep.txt'}
if scenario in {'cleanup', 'tool_limit', 'no_export'}:
expected_files.add('answer.txt')
assert set(inspection.stdout.split()) == expected_files
status = await RunnerBoxService(box).status(context)
assert status['used'] == 1 and status['remaining'] == 0, status
assert manager.bindings[0][0] == manager.bindings[1][0]
assert manager.bindings[0][1] != manager.bindings[1][1]
assert manager.bindings[0][0] == manager.bindings[-1][0]
assert manager.bindings[0][1] != manager.bindings[-1][1]
if repair_export:
assert list(manager.calls_by_run.values()) == ([1, 1] if scenario == 'tool_limit' else [2, 2])
feedback = [
text for payload in _invoke_payload_texts(fake) for text in payload if 'LANGBOT_EXPORT_V1:' in text
]
assert feedback and all(
'"limit":8' in text.replace(' ', '') and '"actual":25' in text.replace(' ', '') for text in feedback
)
assert all(
'file_bytes' in text and 'answer.txt' in text and 'files_retained' in text for text in feedback
)
expected_exports = {
'normal': 1,
'repair': 2,
'streaming': 2,
'tool_limit': 2,
'cleanup': 2,
'invalid': 1,
'timeout': 1,
'cancelled': 2,
'no_export': 0,
}[scenario]
assert len(export_calls) == 2 * expected_exports
from langbot_plugin.box.errors import BoxCapacityExceededError
with pytest.raises(BoxCapacityExceededError):
+11 -11
View File
@@ -1,9 +1,9 @@
{
"repository": "langbot-app/langbot-plugins",
"revision": "7ad54b1a139d88113bc55128326800ed29779737",
"revision": "998b822c412d2604f3c5eca91f04519d98a51b15",
"contract": "Current dedicated-workspace and trusted shared-installation identity; see each plugin SHARED_RUNTIME.md.",
"files": {
"Runner/dify-agent/components/runner/default.py": "0bcaff74c64c89be2bddb4be5871ce148a814676b50987a65f95228ecb7da8fa",
"Runner/dify-agent/components/runner/default.py": "8ca64052fb8df872272726aa060e21ba3b4afa454346b6babcc7e9d2df600980",
"Runner/dify-agent/pkg/scoped_identity.py": "5d03d0c56aaf3301bc5d594905ecf862151fa2abc73daba1a6596f0fdc16374a",
"Runner/dify-agent/pkg/dify_client.py": "607ed8d610d916e5b64ed20699e3de6e2216b238a5fecc9cabcdb75533b8dc56",
"Runner/n8n-agent/components/runner/default.py": "2719ec0a8ff779e90e61bd0209653fb9a1eab4b552877cfcbd4d3c9ce803fcf1",
@@ -18,24 +18,24 @@
},
"local_agent_files": {
"Runner/LocalAgent/assets/icon.svg": "7e1cb51793e898fca810ec72ba6fe9af377074f52139cdd56c93e87f3459f964",
"Runner/LocalAgent/components/runner/default.py": "c63dc2697c61184e496d2fbed9808ccac23fb706a7dcccf34292da2b8e0079b9",
"Runner/LocalAgent/components/runner/default.py": "e548096b910e1faaa4a97a2f7f3e63e42a8227bc789e296ff546c07ab27f3e55",
"Runner/LocalAgent/components/runner/default.yaml": "c1364cf0f211162bb701e27ed92b351fd9ff4cfa0b4469cfeb83ad58136cdf47",
"Runner/LocalAgent/main.py": "0cde45d7f6f411563fbded09be70d0a497c7c7999b5eaa27f83052383348277f",
"Runner/LocalAgent/manifest.yaml": "e71e03f2670dade9ae43e58ddf787bb87254ba9f05e1a691822679f3641bb885",
"Runner/LocalAgent/pkg/__init__.py": "ce5142b4971e8d7945fb3c202bacbc28011b033de0ddcb612d5d29106d6985ec",
"Runner/LocalAgent/pkg/agent_core/__init__.py": "56e95f2391f796e17aee6a168603853ea78b59df97a136bfa33532aede2fa413",
"Runner/LocalAgent/pkg/agent_core/langbot.py": "dc16818ac81dacf37dd6ee799dec4fcd8235ffab4602ede30d29633e78626fc0",
"Runner/LocalAgent/pkg/agent_core/loop.py": "47451d5935d79b0a632fc87f3d94a7d0ecf1b50621141ea8b1031dd87755d5ad",
"Runner/LocalAgent/pkg/agent_core/types.py": "df82b1a17cd2b73f65ebbde22ab8f204ffd27b8d68814b404a1ecce9988e1fcf",
"Runner/LocalAgent/pkg/box.py": "82c05a55bd59b41e844f4613563e518768921fe4fd34bcb4c14c86e395ccd021",
"Runner/LocalAgent/pkg/agent_core/langbot.py": "6bc0e6ee89e8ba3705d14545169f25f54a0c56dc5da89bd8384b3e22e3f21fea",
"Runner/LocalAgent/pkg/agent_core/loop.py": "6a3d67945dee199b4820a9788b207e148355420fa1216bbb82face2fe361394f",
"Runner/LocalAgent/pkg/agent_core/types.py": "78053571e662fd16d42b9f6bd506f282c54adec317cbc8cbf6ed0deab80df4e0",
"Runner/LocalAgent/pkg/box.py": "9ddc2e670234a4be0d0946d9acc376748c8a6fa7714f47c30f8355a1739e499f",
"Runner/LocalAgent/pkg/config.py": "8dee31aecbcdba4c1e79e1735499c3c7ee826ec395032b045169652b38216faf",
"Runner/LocalAgent/pkg/context_pipeline.py": "4c6da3c48faf28985b8bad013fe15106705cd3e025f0c55708b463e4ea07eb05",
"Runner/LocalAgent/pkg/context_pipeline.py": "8f338aa10e9ee384719f215f6e87607baa6caae970fd0aab04303103502c5298",
"Runner/LocalAgent/pkg/messages.py": "620a0a225e95c5f2c8c6e977801413e553d4a1da3fb2ddea14e527ad41f7a3af",
"Runner/LocalAgent/pkg/model_calling.py": "29989b102c8f649e1e65c7c5719761ed39d357d37d57bfa321976d48fb11b76c",
"Runner/LocalAgent/pkg/model_calling.py": "3ebdb8900bb0de5b14e84bf027cc4ff01e59a4669d28dcede7ca3dcea3a3de4c",
"Runner/LocalAgent/pkg/rag.py": "ea407af8d4f6fe5b00cde72c7d85ccc541d6fc46254188c372351d93da4a1f0f",
"Runner/LocalAgent/pkg/run_assembly.py": "ab08c6a18a108cfc6048f158b09ad874577451072dce0bbaa3aa9d15c33a3afe",
"Runner/LocalAgent/pyproject.toml": "ef14f2ed5d4d78591d2f1c0ac0f05856d558dae71da330a203abf3160356e6b1",
"Runner/LocalAgent/pyproject.toml": "f518d499922583c140d5f279bfaacc6f50c1b576e5cec15d170199266820ef84",
"Runner/LocalAgent/requirements.txt": "dd4542c179fa98c8341e7a0b582eb741a70633cd621f4509d09a9ff5c7e52c76",
"Runner/LocalAgent/uv.lock": "4c3c7655ef8c3984e7b720dea346ea67b4d29e8c6d543037779008c3bca074a8"
"Runner/LocalAgent/uv.lock": "39d5851bd787d5207cad0b853b123b02e938fa00124d6f48e2f07c9f3bb770c2"
}
}
@@ -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()
+5 -5
View File
@@ -1938,7 +1938,7 @@ class TestAttachmentHelpers:
{'name': 'report.pdf', 'b64': 'CCC'},
{'name': 'sub/dir/photo.JPG', 'b64': 'DDD'},
{'name': 'noext', 'b64': 'EEE'},
{'name': 'skip', 'b64': ''}, # dropped (no payload)
{'name': 'empty.txt', 'b64': ''}, # valid zero-byte file
]
out = BoxService._classify_outbound_entries(entries)
by_name = {a['name']: a for a in out}
@@ -1951,7 +1951,7 @@ class TestAttachmentHelpers:
# nested path collapses to basename, case-insensitive ext
assert by_name['photo.JPG']['type'] == 'Image'
assert by_name['noext']['type'] == 'File'
assert 'skip' not in by_name
assert by_name['empty.txt']['base64'] == ''
@pytest.mark.asyncio
async def test_component_to_bytes_from_data_uri(self):
@@ -2092,8 +2092,8 @@ class TestInboundOutboundRoundTrip:
calls = []
async def fake_client_execute(spec, *, action_context=None):
assert action_context.workspace_uuid == query.workspace_uuid
async def fake_client_execute(spec, *, action_context):
assert action_context == _ACTION_CONTEXT
cmd = spec.cmd
calls.append(cmd)
if 'os.scandir' in cmd:
@@ -2294,7 +2294,7 @@ class TestAttachmentHostPath:
for index in range(513):
os.symlink(harmless_target, os.path.join(outbox, f'entry-{index}'))
with pytest.raises(BoxValidationError, match='symbolic link'):
with pytest.raises(BoxValidationError, match='directory-entry limit'):
await service.collect_outbound_attachments(query)
assert harmless_target.read_bytes() == b'x'
+434
View File
@@ -0,0 +1,434 @@
"""Outbox failures must retain files; fallback reads use the bound Workspace."""
import asyncio
import base64
import errno
import json
import os
from pathlib import Path
from unittest.mock import AsyncMock, Mock
import pytest
from langbot.pkg.box import secure_fs
from langbot.pkg.box.service import BoxService
from langbot_plugin.box.client import BoxRuntimeClient
from langbot_plugin.box.errors import BoxError, BoxValidationError
from langbot_plugin.box.models import BoxExecutionResult, BoxExecutionStatus
from tests.unit_tests.box.test_box_service import make_app, make_query, _CONTEXT
@pytest.fixture
def env(tmp_path):
app = make_app(Mock(), allowed_mount_roots=[str(tmp_path)], host_root=str(tmp_path / 'box'))
box = BoxService(app, client=Mock(spec=BoxRuntimeClient))
box._available = True
box.default_workspace = str(tmp_path / 'box' / 'default')
workspace = Path(box._tenant_workspace(_CONTEXT))
query = make_query()
outbox = workspace / 'outbox' / query.query_uuid
outbox.mkdir(parents=True)
# The actual generated exec reader runs against this test Workspace.
box.OUTBOX_MOUNT_DIR = str(workspace / 'outbox')
return box, query, outbox
def result(stdout='[]', exit_code=0):
return BoxExecutionResult(
session_id='box-test',
backend_name='test',
status=BoxExecutionStatus.COMPLETED,
exit_code=exit_code,
stdout=stdout,
duration_ms=1,
)
async def execute_script(spec, *, action_context):
assert action_context.workspace_uuid == _CONTEXT.workspace_uuid
assert action_context.instance_uuid == _CONTEXT.instance_uuid
assert action_context.placement_generation == _CONTEXT.placement_generation
process = await asyncio.create_subprocess_exec(
'/bin/sh',
'-c',
spec.cmd,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
)
stdout, stderr = await asyncio.wait_for(process.communicate(), timeout=5)
return BoxExecutionResult(
session_id=spec.session_id,
backend_name='test',
status=BoxExecutionStatus.COMPLETED,
exit_code=process.returncode,
stdout=stdout.decode(),
stderr=stderr.decode(),
duration_ms=1,
)
@pytest.mark.asyncio
@pytest.mark.parametrize('kind', ['file', 'directory', 'query_directory'])
async def test_unreadable_output_is_not_empty_and_is_retained_when_fallback_fails(env, kind):
if os.geteuid() == 0:
pytest.skip('requires non-root filesystem permission checks')
box, query, outbox = env
nested = outbox / 'nested'
nested.mkdir()
file = nested / 'answer.txt'
file.write_bytes(b'expected')
unreadable = {'file': file, 'directory': nested, 'query_directory': outbox}[kind]
mode = unreadable.stat().st_mode & 0o777
box.client.execute = AsyncMock(side_effect=execute_script)
box._clear_outbox = AsyncMock()
try:
unreadable.chmod(0)
with pytest.raises(BoxError):
await box.collect_outbound_attachments(query)
box.client.execute.assert_awaited_once()
box._clear_outbox.assert_not_awaited()
finally:
unreadable.chmod(mode)
assert file.read_bytes() == b'expected'
@pytest.mark.asyncio
async def test_permission_fallback_retries_all_files_once_in_the_bound_box(env, monkeypatch):
box, query, outbox = env
(outbox / 'a.txt').write_bytes(b'first')
(outbox / 'b.txt').write_bytes(b'second')
original_open = secure_fs.os.open
def denied_open(path, flags, *args, **kwargs):
if path == 'b.txt' and not flags & os.O_DIRECTORY:
raise PermissionError(errno.EACCES, 'denied', path)
return original_open(path, flags, *args, **kwargs)
monkeypatch.setattr(secure_fs.os, 'open', denied_open)
monkeypatch.setattr(secure_fs.os, 'supports_dir_fd', secure_fs.os.supports_dir_fd | {denied_open})
box.client.execute = AsyncMock(side_effect=execute_script)
attachments = await box.collect_outbound_attachments(query)
assert {a['name']: base64.b64decode(a['base64']) for a in attachments} == {
'a.txt': b'first',
'b.txt': b'second',
}
assert list(outbox.iterdir()) == []
box.client.execute.assert_awaited_once()
spec = box.client.execute.await_args.args[0]
assert spec.session_id == query._box_binding.session_id
assert spec.host_path == str(outbox.parent.parent)
@pytest.mark.asyncio
@pytest.mark.parametrize('failure', ['transport', 'exit', 'empty', 'json', 'shape', 'base64', 'path', 'payload_limit'])
async def test_exec_failure_or_malformed_response_never_cleans_outbox(env, failure, monkeypatch):
box, query, outbox = env
file = outbox / 'answer.txt'
file.write_bytes(b'keep')
monkeypatch.setattr(box, '_host_query_dir', lambda *args: None)
box._clear_outbox = AsyncMock()
replies = {
'exit': result(exit_code=1),
'empty': result(''),
'json': result('not json'),
'shape': result('{}'),
'base64': result('[{"name":"a","b64":"!"}]'),
'path': result('[{"name":"../a","b64":"YQ=="}]'),
'payload_limit': result(json.dumps([{'name': 'a', 'b64': base64.b64encode(b'large').decode()}])),
}
if failure == 'payload_limit':
monkeypatch.setattr(box, '_ATTACHMENT_MAX_BYTES', 4)
box.client.execute = AsyncMock(
side_effect=RuntimeError('transport failure') if failure == 'transport' else None,
return_value=replies.get(failure),
)
with pytest.raises(BoxError):
await box.collect_outbound_attachments(query)
box._clear_outbox.assert_not_awaited()
assert file.read_bytes() == b'keep'
@pytest.mark.asyncio
@pytest.mark.parametrize('remote', [False, True])
@pytest.mark.parametrize('limit', ['file_bytes', 'total_bytes', 'file_count', 'depth', 'entries', 'directories'])
async def test_export_limit_failure_retains_every_file(env, monkeypatch, remote, limit):
box, query, outbox = env
if limit == 'file_bytes':
monkeypatch.setattr(box, '_ATTACHMENT_MAX_BYTES', 3)
if limit == 'total_bytes':
monkeypatch.setattr(box, '_ATTACHMENT_MAX_TOTAL_BYTES', 7)
if limit == 'file_count':
monkeypatch.setattr(box, '_ATTACHMENT_MAX_FILES', 1)
(outbox / 'one').write_bytes(b'1234')
(outbox / 'two').write_bytes(b'5678')
if limit == 'depth':
(outbox / Path(*(['nested'] * 9))).mkdir(parents=True)
if limit == 'entries':
for i in range(1001):
(outbox / f'link-{i}').symlink_to('one')
if limit == 'directories':
for i in range(65):
(outbox / f'directory-{i}').mkdir()
before = sorted(str(p.relative_to(outbox)) for p in outbox.rglob('*'))
box._clear_outbox = AsyncMock()
box.client.execute = AsyncMock(side_effect=execute_script)
if remote:
monkeypatch.setattr(box, '_host_query_dir', lambda *args: None)
with pytest.raises(BoxError):
await box.collect_outbound_attachments(query)
assert sorted(str(p.relative_to(outbox)) for p in outbox.rglob('*')) == before
assert (outbox / 'one').read_bytes() == b'1234'
assert (outbox / 'two').read_bytes() == b'5678'
box._clear_outbox.assert_not_awaited()
if not remote:
box.client.execute.assert_not_awaited()
@pytest.mark.asyncio
@pytest.mark.parametrize('remote', [False, True])
async def test_exact_limits_succeed_and_other_run_is_untouched(env, monkeypatch, remote):
box, query, outbox = env
monkeypatch.setattr(box, '_ATTACHMENT_MAX_FILES', 2)
monkeypatch.setattr(box, '_ATTACHMENT_MAX_BYTES', 4)
monkeypatch.setattr(box, '_ATTACHMENT_MAX_TOTAL_BYTES', 8)
(outbox / 'one').write_bytes(b'1234')
(outbox / 'two').write_bytes(b'5678')
other = outbox.parent / 'other-run'
other.mkdir()
(other / 'secret').write_bytes(b'other run')
(outbox / 'link').symlink_to(other / 'secret')
box.client.execute = AsyncMock(side_effect=execute_script)
if remote:
monkeypatch.setattr(box, '_host_query_dir', lambda *args: None)
box.execute_tool = AsyncMock(
side_effect=lambda *args: secure_fs.reset_directory(str(outbox.parent.parent), 'outbox', outbox.name)
)
attachments = await box.collect_outbound_attachments(query)
assert {a['name']: base64.b64decode(a['base64']) for a in attachments} == {'one': b'1234', 'two': b'5678'}
assert list(outbox.iterdir()) == []
assert (other / 'secret').read_bytes() == b'other run'
@pytest.mark.asyncio
async def test_unsafe_host_path_never_uses_privileged_fallback(env):
box, query, outbox = env
outbox.rmdir()
other = outbox.parent.parent / 'other-workspace'
other.mkdir()
(other / 'secret').write_bytes(b'private')
outbox.symlink_to(other)
box.client.execute = AsyncMock()
with pytest.raises(BoxValidationError, match='symbolic link'):
await box.collect_outbound_attachments(query)
box.client.execute.assert_not_awaited()
assert (other / 'secret').read_bytes() == b'private'
@pytest.mark.asyncio
@pytest.mark.parametrize('file_size,file_count', [(300 * 1024, 1), (1024 * 1024, 6)])
async def test_exec_export_preserves_upstream_large_attachment_limits(env, monkeypatch, file_size, file_count):
box, query, outbox = env
payload = b'x' * file_size
for index in range(file_count):
(outbox / f'file-{index}.bin').write_bytes(payload)
monkeypatch.setattr(box, '_host_query_dir', lambda *args: None)
box.client.execute = AsyncMock(side_effect=execute_script)
box.execute_tool = AsyncMock(
side_effect=lambda *args: secure_fs.reset_directory(str(outbox.parent.parent), 'outbox', outbox.name)
)
attachments = await box.collect_outbound_attachments(query)
assert {a['name']: base64.b64decode(a['base64']) for a in attachments} == {
f'file-{index}.bin': payload for index in range(file_count)
}
assert list(outbox.iterdir()) == []
@pytest.mark.asyncio
async def test_host_io_error_is_not_retried_as_a_permission_failure(env, monkeypatch):
box, query, outbox = env
file = outbox / 'answer.txt'
file.write_bytes(b'keep')
original = secure_fs.os.open
def fail_open(path, flags, *args, **kwargs):
if path == 'answer.txt':
raise OSError(errno.EIO, 'I/O failure')
return original(path, flags, *args, **kwargs)
monkeypatch.setattr(secure_fs.os, 'open', fail_open)
monkeypatch.setattr(secure_fs.os, 'supports_dir_fd', secure_fs.os.supports_dir_fd | {fail_open})
box.client.execute = AsyncMock()
box._clear_outbox = AsyncMock()
with pytest.raises(BoxError, match='retained'):
await box.collect_outbound_attachments(query)
box.client.execute.assert_not_awaited()
box._clear_outbox.assert_not_awaited()
assert file.read_bytes() == b'keep'
@pytest.mark.asyncio
async def test_cancelled_export_does_not_clear_output(env, monkeypatch):
box, query, outbox = env
(outbox / 'answer.txt').write_bytes(b'keep')
monkeypatch.setattr(box, '_host_query_dir', lambda *args: None)
box.client.execute = AsyncMock(side_effect=asyncio.CancelledError())
box._clear_outbox = AsyncMock()
with pytest.raises(asyncio.CancelledError):
await box.collect_outbound_attachments(query)
box._clear_outbox.assert_not_awaited()
assert (outbox / 'answer.txt').read_bytes() == b'keep'
@pytest.mark.asyncio
async def test_stale_workspace_result_does_not_clear_output(env, monkeypatch):
box, query, outbox = env
(outbox / 'answer.txt').write_bytes(b'keep')
monkeypatch.setattr(box, '_host_query_dir', lambda *args: None)
binding = box.ap.workspace_service.get_execution_binding.return_value
box.ap.workspace_service.get_execution_binding.side_effect = [binding, BoxError('stale generation')]
box.client.execute = AsyncMock(return_value=result('[{"name":"answer.txt","b64":"a2VlcA=="}]'))
box._clear_outbox = AsyncMock()
with pytest.raises(BoxError, match='stale generation'):
await box.collect_outbound_attachments(query)
box._clear_outbox.assert_not_awaited()
assert (outbox / 'answer.txt').read_bytes() == b'keep'
@pytest.mark.asyncio
@pytest.mark.parametrize('limit', ['files', 'bytes'])
async def test_run_limit_failure_preserves_files_and_export_references_for_retry(env, monkeypatch, limit):
from langbot.pkg.box.runner import RunnerBoxService, binding_for
box, query, outbox = env
api = RunnerBoxService(box)
box.client.execute = AsyncMock(side_effect=execute_script)
binding = binding_for(query)
(outbox / 'one').write_bytes(b'ab')
(outbox / 'two').write_bytes(b'cd')
if limit == 'files':
binding.exported.update({f'existing-{i}': {'size': 1} for i in range(99)})
else:
monkeypatch.setattr(box, '_ATTACHMENT_MAX_TOTAL_BYTES', 5)
binding.exported['existing'] = {'size': 2}
before = dict(binding.exported)
with pytest.raises(BoxValidationError):
await api.export_files(query, [str(p) for p in outbox.iterdir()])
assert binding.exported == before
assert (outbox / 'one').read_bytes() == b'ab'
assert (outbox / 'two').read_bytes() == b'cd'
# After the caller reduces its output, it can retry without duplicate IDs.
(outbox / 'two').unlink()
exported = await api.export_files(query, [str(p) for p in outbox.iterdir()])
assert len(binding.exported) == len(before) + 1
assert any(item['name'] == 'one' for item in exported['items'] if 'name' in item)
assert list(outbox.iterdir()) == []
@pytest.mark.asyncio
async def test_unavailable_box_does_not_turn_runner_export_into_empty_cleanup(env):
from langbot.pkg.box.runner import RunnerBoxService
box, query, outbox = env
box._available = False
(outbox / 'answer.txt').write_bytes(b'keep')
with pytest.raises(BoxError, match='not available'):
await RunnerBoxService(box).export_files(query, [str(outbox / 'answer.txt')])
assert (outbox / 'answer.txt').read_bytes() == b'keep'
@pytest.mark.asyncio
@pytest.mark.parametrize('remote', [False, True])
async def test_export_diagnostics_include_limit_actual_relative_path_and_retention(env, monkeypatch, remote):
from langbot.pkg.box.export_errors import BoxExportError
box, query, outbox = env
(outbox / 'nested').mkdir()
(outbox / 'nested' / 'answer.txt').write_bytes(b'1234')
monkeypatch.setattr(box, '_ATTACHMENT_MAX_BYTES', 3)
box.client.execute = AsyncMock(side_effect=execute_script)
if remote:
monkeypatch.setattr(box, '_host_query_dir', lambda *args: None)
with pytest.raises(BoxExportError) as caught:
await box.collect_outbound_attachments(query)
error = caught.value.error
assert error.code == 'box.export.limit_exceeded' and error.retryable
assert error.details['limit_type'] == 'file_bytes'
assert error.details['limit'] == 3 and error.details['actual'] == 4
assert error.details['relative_paths'] == ['nested/answer.txt']
assert error.details['files_retained'] is True and error.details['suggested_action']
assert (outbox / 'nested' / 'answer.txt').read_bytes() == b'1234'
@pytest.mark.asyncio
@pytest.mark.parametrize(
'stdout,code,actual',
[
('[{"name":', 'box.export.invalid_response', None),
('not json', 'box.export.invalid_response', None),
(
'[{\n... [raw output clipped at 1048576 bytes, 12 bytes discarded]',
'box.export.transport_truncated',
1048588,
),
('[{\n... [output clipped at 1048576 bytes]', 'box.export.transport_truncated', None),
],
)
async def test_only_explicit_transport_clipping_is_repairable(env, monkeypatch, stdout, code, actual):
from langbot.pkg.box.export_errors import BoxExportError
box, query, outbox = env
(outbox / 'answer.txt').write_bytes(b'keep')
monkeypatch.setattr(box, '_host_query_dir', lambda *args: None)
box.client.execute = AsyncMock(return_value=result(stdout))
with pytest.raises(BoxExportError) as caught:
await box.collect_outbound_attachments(query)
assert caught.value.error.code == code
assert caught.value.error.retryable is (code == 'box.export.transport_truncated')
assert caught.value.error.details['actual'] == actual
assert (outbox / 'answer.txt').read_bytes() == b'keep'
def test_untrusted_script_error_is_not_classified_as_a_quota_failure():
from langbot.pkg.box.export_errors import script_export_error
payload = 'LANGBOT_OUTBOX_LIMIT:' + json.dumps({'kind': 'file_count', 'limit': 20, 'actual': 21, 'path': 'a.txt'})
assert script_export_error(payload, 73, {'file_count': 20}) is not None
assert script_export_error(payload, 1, {'file_count': 20}) is None
assert script_export_error(payload, 73, {'file_count': 10}) is None
assert script_export_error(payload.replace('a.txt', '../a'), 73, {'file_count': 20}) is None
@pytest.mark.asyncio
async def test_selected_file_quota_reports_absolute_sandbox_path_and_retains_batch(env, monkeypatch):
from langbot.pkg.box.export_errors import BoxExportError
box, query, outbox = env
(outbox / 'answer.txt').write_bytes(b'1234')
(outbox / 'keep.txt').write_bytes(b'unselected')
monkeypatch.setattr(box, '_ATTACHMENT_MAX_BYTES', 3)
box.client.execute = AsyncMock(side_effect=execute_script)
path = str(outbox / 'answer.txt')
with pytest.raises(BoxExportError) as caught:
await box.collect_selected_files(query, [path])
details = caught.value.error.details
assert details['limit_type'] == 'file_bytes'
assert details['limit'] == 3 and details['actual'] == 4
assert details['sandbox_paths'] == [path] and details['relative_paths'] == []
assert details['files_retained'] is True
assert (outbox / 'answer.txt').read_bytes() == b'1234'
assert (outbox / 'keep.txt').read_bytes() == b'unselected'
@pytest.mark.asyncio
async def test_directory_export_still_rejects_absolute_response_paths(env, monkeypatch):
from langbot.pkg.box.export_errors import BoxExportError
box, query, outbox = env
monkeypatch.setattr(box, '_host_query_dir', lambda *args: None)
box.client.execute = AsyncMock(return_value=result('[{"name":"/unrequested","b64":"YQ=="}]'))
with pytest.raises(BoxExportError) as caught:
await box.collect_outbound_attachments(query)
assert caught.value.error.code == 'box.export.invalid_response'
@@ -158,7 +158,7 @@ def racing_open(path, flags, *args, **kwargs):
if replacement == 'symlink':
os.symlink({str(secret)!r}, path, dir_fd=fd)
elif replacement == 'fifo':
os.mkfifo(path, dir_fd=fd)
os.mkfifo({str(current / 'answer.txt')!r})
else:
os.mkdir(path, dir_fd=fd)
child = original_open(path, os.O_RDONLY | os.O_DIRECTORY, dir_fd=fd)
@@ -205,24 +205,25 @@ def test_sandbox_output_limits_fail_without_removing_files(tmp_path, limit):
assert set(current.iterdir()) == before
def test_sandbox_output_count_and_depth_are_bounded(tmp_path):
import json
def test_sandbox_output_count_and_depth_limits_retain_all_files(tmp_path):
current = tmp_path / 'workspace' / 'outbox' / 'run'
current.mkdir(parents=True)
(current / 'a.txt').write_bytes(b'a')
(current / 'b.txt').write_bytes(b'b')
result = _run_script(_script(tmp_path, max_files=1))
assert result.returncode == 0, result.stderr
assert len(json.loads(result.stdout)) == 1
assert result.returncode != 0
assert 'file-count limit' in result.stderr
assert (current / 'a.txt').read_bytes() == b'a'
assert (current / 'b.txt').read_bytes() == b'b'
(current / 'a.txt').unlink()
(current / 'b.txt').unlink()
deep = current.joinpath(*('nested' for _ in range(9)))
deep.mkdir(parents=True)
(deep / 'too-deep.txt').write_bytes(b'not exported')
result = _run_script(_script(tmp_path))
assert result.returncode == 0, result.stderr
assert json.loads(result.stdout) == []
assert result.returncode != 0
assert 'depth limit' in result.stderr
assert (deep / 'too-deep.txt').read_bytes() == b'not exported'
@pytest.mark.parametrize('operation', ['read', 'clear'])
+61 -5
View File
@@ -21,6 +21,8 @@ def service():
enabled=True,
managed_admission_required=False,
_ATTACHMENT_MAX_TOTAL_BYTES=1000000,
clear_selected_files=AsyncMock(return_value=None),
_ATTACHMENT_MAX_FILES=100,
create_session=AsyncMock(side_effect=create),
require_workspace_sandbox=AsyncMock(side_effect=lambda context: context),
_action_context=lambda context: context,
@@ -110,12 +112,12 @@ async def test_selective_import_is_idempotent_and_separates_duplicate_names():
async def test_export_does_not_send_and_references_cannot_cross_runs_or_repeat():
api = service()
q = SimpleNamespace(_box_binding=RunBoxBinding('run', 'box', {}, 'run'))
api.box.collect_outbound_attachments = AsyncMock(
api.box.collect_selected_files = AsyncMock(
return_value=[
{'name': 'answer.txt', 'type': 'File', 'base64': base64.b64encode(b'answer').decode()},
]
)
result = await api.export_files(q)
result = await api.export_files(q, ['/workspace/a'])
file = result['items'][0]
assert 'base64' not in file and file['size'] == 6
other = SimpleNamespace(_box_binding=RunBoxBinding('other-run', 'box', {}, 'other-run'))
@@ -172,11 +174,65 @@ def test_attachment_references_follow_metadata_not_platform_list_order():
async def test_image_export_accepts_data_url_and_enforces_total_bytes():
api = service()
q = SimpleNamespace(_box_binding=RunBoxBinding('run', 'box', {}, 'run'))
api.box.collect_outbound_attachments = AsyncMock(
api.box.collect_selected_files = AsyncMock(
return_value=[{'name': 'a.png', 'type': 'Image', 'base64': 'data:image/png;base64,YQ=='}]
)
result = await api.export_files(q)
result = await api.export_files(q, ['/workspace/a'])
assert result['items'][0]['size'] == 1
# Retrying unchanged content reuses the accepted ID; new content consumes budget.
assert await api.export_files(q, ['/workspace/a']) == result
api.box.collect_selected_files.return_value[0]['base64'] = 'data:image/png;base64,Yg=='
api.box._ATTACHMENT_MAX_TOTAL_BYTES = 1
with pytest.raises(BoxValidationError, match='byte limit'):
await api.export_files(q)
await api.export_files(q, ['/workspace/a'])
@pytest.mark.asyncio
async def test_cleanup_retry_preserves_ids_and_never_redelivers_consumed_files():
api = service()
q = SimpleNamespace(_box_binding=RunBoxBinding('run', 'box', {}, 'run'))
# A best-effort cleanup can leave the same files in the outbox.
api.box.collect_selected_files = AsyncMock(
return_value=[
{'name': 'same.txt', '_outbox_path': 'a/same.txt', 'type': 'File', 'base64': 'YQ=='},
{'name': 'same.txt', '_outbox_path': 'b/same.txt', 'type': 'File', 'base64': 'YQ=='},
{'name': 'empty.txt', '_outbox_path': 'empty.txt', 'type': 'File', 'base64': ''},
]
)
first = await api.export_files(q, ['/workspace/a'])
assert len(first['items']) == 3
assert all('_outbox_path' not in item for item in first['items'])
assert await api.export_files(q, ['/workspace/a']) == first
exported_message(q, [item['id'] for item in first['items']], consume=True)
assert await api.export_files(q, ['/workspace/a']) == {'items': [], 'warning': None}
assert len(q._box_binding.exported) == 3
api.box.collect_selected_files.return_value[0]['base64'] = 'Yg=='
changed = await api.export_files(q, ['/workspace/a'])
assert len(changed['items']) == 1
assert changed['items'][0]['id'] not in {item['id'] for item in first['items']}
@pytest.mark.asyncio
async def test_run_quota_failure_is_atomic_and_reports_remaining_budget():
from langbot.pkg.box.export_errors import BoxExportError
api = service()
api.box._ATTACHMENT_MAX_TOTAL_BYTES = 2
q = SimpleNamespace(_box_binding=RunBoxBinding('run', 'box', {}, 'run'))
api.box.collect_selected_files = AsyncMock(
return_value=[
{'name': 'a.txt', 'type': 'File', 'base64': 'YQ=='},
]
)
await api.export_files(q, ['/workspace/a'])
api.box.collect_selected_files.return_value = [
{'name': 'b.txt', 'type': 'File', 'base64': 'Yg=='},
{'name': 'c.txt', 'type': 'File', 'base64': 'Yw=='},
]
with pytest.raises(BoxExportError) as caught:
await api.export_files(q, ['/workspace/a'])
assert caught.value.error.details['limit'] == 2
assert caught.value.error.details['actual'] == 3
assert caught.value.error.details['remaining'] == 1
assert len(q._box_binding.exported) == len(q._box_binding.exported_fingerprints) == 1
api.box.clear_selected_files.assert_awaited_once()
@@ -0,0 +1,57 @@
import hashlib
import json
import subprocess
import sys
import pytest
from langbot.pkg.box.selected_files import selected_files_script
def run(paths, cleanup=None, limit=100):
return subprocess.run(
[
sys.executable,
'-c',
selected_files_script(list(map(str, paths)), max_file_bytes=limit, max_total_bytes=limit, cleanup=cleanup),
],
capture_output=True,
text=True,
)
def test_selected_read_and_cleanup(tmp_path):
a, b = tmp_path / 'a', tmp_path / 'b'
a.write_bytes(b'hello')
b.write_bytes(b'keep')
result = run([a])
assert result.returncode == 0, result.stderr
assert json.loads(result.stdout) == [{'name': str(a), 'b64': 'aGVsbG8='}]
assert a.exists() and b.exists()
assert run([a], {str(a): hashlib.sha256(b'hello').hexdigest()}).returncode == 0
assert not a.exists() and b.read_bytes() == b'keep'
@pytest.mark.parametrize('bad', ['missing', 'symlink', 'directory', 'size', 'fifo'])
def test_failed_batch_retains_all(tmp_path, bad):
a, b = tmp_path / 'a', tmp_path / 'b'
a.write_bytes(b'hello')
if bad == 'symlink':
b.symlink_to(a)
elif bad == 'directory':
b.mkdir()
elif bad == 'size':
b.write_bytes(b'x' * 101)
elif bad == 'fifo':
import os
os.mkfifo(b)
result = run([a, b])
assert result.returncode != 0
assert a.read_bytes() == b'hello'
def test_changed_file_is_retained(tmp_path):
a = tmp_path / 'a'
a.write_bytes(b'changed')
assert run([a], {str(a): hashlib.sha256(b'old').hexdigest()}).returncode != 0
assert a.read_bytes() == b'changed'
Generated
+2 -6
View File
@@ -2190,7 +2190,7 @@ requires-dist = [
{ name = "gewechat-client", specifier = ">=0.1.5" },
{ name = "html2text", specifier = ">=2024.2.26" },
{ name = "httpx", extras = ["socks"], specifier = ">=0.28.1" },
{ name = "langbot-plugin", specifier = "==0.7.13" },
{ name = "langbot-plugin", git = "https://github.com/langbot-app/langbot-plugin-sdk.git?rev=b300830b0e5339e7989be237efa811bedf761d55" },
{ name = "langchain", specifier = ">=1.3.9" },
{ name = "langchain-core", specifier = ">=1.3.3" },
{ name = "langchain-text-splitters", specifier = ">=1.1.2" },
@@ -2261,7 +2261,7 @@ dev = [
[[package]]
name = "langbot-plugin"
version = "0.7.13"
source = { registry = "https://pypi.org/simple" }
source = { git = "https://github.com/langbot-app/langbot-plugin-sdk.git?rev=b300830b0e5339e7989be237efa811bedf761d55#b300830b0e5339e7989be237efa811bedf761d55" }
dependencies = [
{ name = "aiofiles" },
{ name = "aiohttp" },
@@ -2281,10 +2281,6 @@ dependencies = [
{ name = "watchdog" },
{ name = "websockets" },
]
sdist = { url = "https://files.pythonhosted.org/packages/50/49/9d3b0ba59ccc11216b7780d99587c483c49c22095ce29b1ed0c2b30ba0a7/langbot_plugin-0.7.13.tar.gz", hash = "sha256:fc8b1127f1774a50b2e0b63c309643ee7fab78a4d5b5fbd0020de1eb1d921e78", size = 713534, upload-time = "2026-10-17T02:20:51.24Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/54/f1/23b0e7e37af68781b394a35c95e56b7169fae4ac5e9bb9946ded794a7758/langbot_plugin-0.7.13-py3-none-any.whl", hash = "sha256:f2ef40472907355dd7997bf4e4d5a2dd2bc563f8b3e1e872d3caec6ffea15c81", size = 458360, upload-time = "2026-10-17T02:20:49.759Z" },
]
[[package]]
name = "langchain"