mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-10-22 08:25:21 +08:00
fix(box): validate and export explicitly selected files (#2641)
* fix(box): retain outbox files when export fails * test(box): retain ownership evidence in CI logs * test(box): exercise LocalAgent export repair through real runtime and Docker Co-authored-by: multica-agent <[email protected]> * fix(box): complete bounded outbox recovery contract * feat(box): export explicitly selected files Co-authored-by: multica-agent <[email protected]> * test(box): exercise explicit export on real Docker Co-authored-by: multica-agent <[email protected]> * test(box): verify explicit exports with live model and Docker Co-authored-by: multica-agent <[email protected]> * fix(box): align explicit export SDK and integration contracts * docs(box): describe selected-file export contract --------- Co-authored-by: HaiYangBG1 <[email protected]> Co-authored-by: Chin Rock <[email protected]> Co-authored-by: multica-agent <[email protected]>
This commit is contained in:
co-authored by
multica-agent
HaiYangBG1
Chin Rock
parent
bce94bd0d9
commit
947d8addea
+7
-1
@@ -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`
|
||||
|
||||
@@ -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.
|
||||
@@ -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" }
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
]
|
||||
],
|
||||
}
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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
@@ -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):
|
||||
|
||||
@@ -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}')
|
||||
|
||||
|
||||
@@ -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
@@ -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()
|
||||
@@ -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'
|
||||
|
||||
|
||||
@@ -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'])
|
||||
|
||||
@@ -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'
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user