From 947d8addea2e7d3a3fde9cda4dd73ce408335a3b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=B5=B7=E6=B4=8B=E9=A5=BC=E5=B9=B2?= Date: Mon, 19 Oct 2026 15:31:33 +0800 Subject: [PATCH] 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 * fix(box): complete bounded outbox recovery contract * feat(box): export explicitly selected files Co-authored-by: multica-agent * test(box): exercise explicit export on real Docker Co-authored-by: multica-agent * test(box): verify explicit exports with live model and Docker Co-authored-by: multica-agent * fix(box): align explicit export SDK and integration contracts * docs(box): describe selected-file export contract --------- Co-authored-by: HaiYangBG1 <181040822+HaiYangBG1@users.noreply.github.com> Co-authored-by: Chin Rock Co-authored-by: multica-agent --- ARCHITECTURE.md | 8 +- docs/BOX_OUTBOX_EXPORT.md | 69 +++ pyproject.toml | 4 + src/langbot/pkg/box/export_errors.py | 123 +++++ src/langbot/pkg/box/runner.py | 68 ++- src/langbot/pkg/box/sandbox_files.py | 30 +- src/langbot/pkg/box/secure_fs.py | 76 +-- src/langbot/pkg/box/selected_files.py | 52 +++ src/langbot/pkg/box/service.py | 173 +++++-- src/langbot/pkg/plugin/box_actions.py | 5 +- tests/e2e/test_local_runner_fake_provider.py | 227 ++++++++- .../legacy_plugin_identity_sources.json | 22 +- .../box/test_live_explicit_export.py | 90 ++++ .../box/test_outbox_export.py | 111 +++++ tests/unit_tests/box/test_box_service.py | 10 +- tests/unit_tests/box/test_outbox_export.py | 434 ++++++++++++++++++ .../unit_tests/box/test_outbox_permissions.py | 17 +- tests/unit_tests/box/test_runner_box.py | 66 ++- tests/unit_tests/box/test_selected_files.py | 57 +++ uv.lock | 8 +- 20 files changed, 1513 insertions(+), 137 deletions(-) create mode 100644 docs/BOX_OUTBOX_EXPORT.md create mode 100644 src/langbot/pkg/box/export_errors.py create mode 100644 src/langbot/pkg/box/selected_files.py create mode 100644 tests/integration_tests/box/test_live_explicit_export.py create mode 100644 tests/integration_tests/box/test_outbox_export.py create mode 100644 tests/unit_tests/box/test_outbox_export.py create mode 100644 tests/unit_tests/box/test_selected_files.py diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index f99f926a8..4733e544d 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -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` diff --git a/docs/BOX_OUTBOX_EXPORT.md b/docs/BOX_OUTBOX_EXPORT.md new file mode 100644 index 000000000..59247a316 --- /dev/null +++ b/docs/BOX_OUTBOX_EXPORT.md @@ -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. diff --git a/pyproject.toml b/pyproject.toml index 0c11489b8..e4eb5814a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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" } diff --git a/src/langbot/pkg/box/export_errors.py b/src/langbot/pkg/box/export_errors.py new file mode 100644 index 000000000..a9dac8878 --- /dev/null +++ b/src/langbot/pkg/box/export_errors.py @@ -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 diff --git a/src/langbot/pkg/box/runner.py b/src/langbot/pkg/box/runner.py index 6e88cb842..c31a72b4c 100644 --- a/src/langbot/pkg/box/runner.py +++ b/src/langbot/pkg/box/runner.py @@ -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 - ] + ], } diff --git a/src/langbot/pkg/box/sandbox_files.py b/src/langbot/pkg/box/sandbox_files.py index 730260149..6d89ec52c 100644 --- a/src/langbot/pkg/box/sandbox_files.py +++ b/src/langbot/pkg/box/sandbox_files.py @@ -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: diff --git a/src/langbot/pkg/box/secure_fs.py b/src/langbot/pkg/box/secure_fs.py index 2a0d2166a..4b0c54d7d 100644 --- a/src/langbot/pkg/box/secure_fs.py +++ b/src/langbot/pkg/box/secure_fs.py @@ -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 diff --git a/src/langbot/pkg/box/selected_files.py b/src/langbot/pkg/box/selected_files.py new file mode 100644 index 000000000..0818d84dc --- /dev/null +++ b/src/langbot/pkg/box/selected_files.py @@ -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)) + """) diff --git a/src/langbot/pkg/box/service.py b/src/langbot/pkg/box/service.py index c4c4552c1..edadc6e43 100644 --- a/src/langbot/pkg/box/service.py +++ b/src/langbot/pkg/box/service.py @@ -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): diff --git a/src/langbot/pkg/plugin/box_actions.py b/src/langbot/pkg/plugin/box_actions.py index e2ff8e8af..74b83cbb6 100644 --- a/src/langbot/pkg/plugin/box_actions.py +++ b/src/langbot/pkg/plugin/box_actions.py @@ -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}') diff --git a/tests/e2e/test_local_runner_fake_provider.py b/tests/e2e/test_local_runner_fake_provider.py index cbb953bad..9be98d76c 100644 --- a/tests/e2e/test_local_runner_fake_provider.py +++ b/tests/e2e/test_local_runner_fake_provider.py @@ -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): diff --git a/tests/fixtures/legacy_plugin_identity_sources.json b/tests/fixtures/legacy_plugin_identity_sources.json index 62c60b864..1e1bb5c65 100644 --- a/tests/fixtures/legacy_plugin_identity_sources.json +++ b/tests/fixtures/legacy_plugin_identity_sources.json @@ -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" } } diff --git a/tests/integration_tests/box/test_live_explicit_export.py b/tests/integration_tests/box/test_live_explicit_export.py new file mode 100644 index 000000000..e01c1aa33 --- /dev/null +++ b/tests/integration_tests/box/test_live_explicit_export.py @@ -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() diff --git a/tests/integration_tests/box/test_outbox_export.py b/tests/integration_tests/box/test_outbox_export.py new file mode 100644 index 000000000..9ea9b2ac6 --- /dev/null +++ b/tests/integration_tests/box/test_outbox_export.py @@ -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() diff --git a/tests/unit_tests/box/test_box_service.py b/tests/unit_tests/box/test_box_service.py index aa7471411..2d55ef52d 100644 --- a/tests/unit_tests/box/test_box_service.py +++ b/tests/unit_tests/box/test_box_service.py @@ -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' diff --git a/tests/unit_tests/box/test_outbox_export.py b/tests/unit_tests/box/test_outbox_export.py new file mode 100644 index 000000000..39ba64c49 --- /dev/null +++ b/tests/unit_tests/box/test_outbox_export.py @@ -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' diff --git a/tests/unit_tests/box/test_outbox_permissions.py b/tests/unit_tests/box/test_outbox_permissions.py index 0adf9f8e5..1b9ccf6f8 100644 --- a/tests/unit_tests/box/test_outbox_permissions.py +++ b/tests/unit_tests/box/test_outbox_permissions.py @@ -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']) diff --git a/tests/unit_tests/box/test_runner_box.py b/tests/unit_tests/box/test_runner_box.py index 74e4262e5..f5632fe4a 100644 --- a/tests/unit_tests/box/test_runner_box.py +++ b/tests/unit_tests/box/test_runner_box.py @@ -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() diff --git a/tests/unit_tests/box/test_selected_files.py b/tests/unit_tests/box/test_selected_files.py new file mode 100644 index 000000000..f783ad7a4 --- /dev/null +++ b/tests/unit_tests/box/test_selected_files.py @@ -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' diff --git a/uv.lock b/uv.lock index 9d7373813..58493a2b3 100644 --- a/uv.lock +++ b/uv.lock @@ -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"