pipelex-api 0.75.0__tar.gz → 0.77.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (43) hide show
  1. {pipelex_api-0.75.0 → pipelex_api-0.77.0}/PKG-INFO +2 -2
  2. pipelex_api-0.77.0/pipelex_api/bundle.py +433 -0
  3. {pipelex_api-0.75.0 → pipelex_api-0.77.0}/pipelex_api/method_source.py +71 -45
  4. {pipelex_api-0.75.0 → pipelex_api-0.77.0}/pipelex_api/routes/pipelex/pipeline.py +93 -39
  5. {pipelex_api-0.75.0 → pipelex_api-0.77.0}/pipelex_api/schemas/models.py +6 -1
  6. pipelex_api-0.75.0/pipelex_api/bundle.py +0 -243
  7. {pipelex_api-0.75.0 → pipelex_api-0.77.0}/.gitignore +0 -0
  8. {pipelex_api-0.75.0 → pipelex_api-0.77.0}/LICENSE +0 -0
  9. {pipelex_api-0.75.0 → pipelex_api-0.77.0}/README.md +0 -0
  10. {pipelex_api-0.75.0 → pipelex_api-0.77.0}/hatch_build.py +0 -0
  11. {pipelex_api-0.75.0 → pipelex_api-0.77.0}/pipelex_api/__init__.py +0 -0
  12. {pipelex_api-0.75.0 → pipelex_api-0.77.0}/pipelex_api/api.toml +0 -0
  13. {pipelex_api-0.75.0 → pipelex_api-0.77.0}/pipelex_api/api_config.py +0 -0
  14. {pipelex_api-0.75.0 → pipelex_api-0.77.0}/pipelex_api/disclosure.py +0 -0
  15. {pipelex_api-0.75.0 → pipelex_api-0.77.0}/pipelex_api/error_types.py +0 -0
  16. {pipelex_api-0.75.0 → pipelex_api-0.77.0}/pipelex_api/error_uri.py +0 -0
  17. {pipelex_api-0.75.0 → pipelex_api-0.77.0}/pipelex_api/errors.py +0 -0
  18. {pipelex_api-0.75.0 → pipelex_api-0.77.0}/pipelex_api/exception_handlers.py +0 -0
  19. {pipelex_api-0.75.0 → pipelex_api-0.77.0}/pipelex_api/json_body.py +0 -0
  20. {pipelex_api-0.75.0 → pipelex_api-0.77.0}/pipelex_api/limits.py +0 -0
  21. {pipelex_api-0.75.0 → pipelex_api-0.77.0}/pipelex_api/main.py +0 -0
  22. {pipelex_api-0.75.0 → pipelex_api-0.77.0}/pipelex_api/method_cache.py +0 -0
  23. {pipelex_api-0.75.0 → pipelex_api-0.77.0}/pipelex_api/middleware.py +0 -0
  24. {pipelex_api-0.75.0 → pipelex_api-0.77.0}/pipelex_api/openapi_responses.py +0 -0
  25. {pipelex_api-0.75.0 → pipelex_api-0.77.0}/pipelex_api/openapi_schema.py +0 -0
  26. {pipelex_api-0.75.0 → pipelex_api-0.77.0}/pipelex_api/problem_document.py +0 -0
  27. {pipelex_api-0.75.0 → pipelex_api-0.77.0}/pipelex_api/py.typed +0 -0
  28. {pipelex_api-0.75.0 → pipelex_api-0.77.0}/pipelex_api/routes/__init__.py +0 -0
  29. {pipelex_api-0.75.0 → pipelex_api-0.77.0}/pipelex_api/routes/health.py +0 -0
  30. {pipelex_api-0.75.0 → pipelex_api-0.77.0}/pipelex_api/routes/pipelex/__init__.py +0 -0
  31. {pipelex_api-0.75.0 → pipelex_api-0.77.0}/pipelex_api/routes/pipelex/agent/__init__.py +0 -0
  32. {pipelex_api-0.75.0 → pipelex_api-0.77.0}/pipelex_api/routes/pipelex/agent/models.py +0 -0
  33. {pipelex_api-0.75.0 → pipelex_api-0.77.0}/pipelex_api/routes/pipelex/codegen.py +0 -0
  34. {pipelex_api-0.75.0 → pipelex_api-0.77.0}/pipelex_api/routes/pipelex/crate_ops.py +0 -0
  35. {pipelex_api-0.75.0 → pipelex_api-0.77.0}/pipelex_api/routes/pipelex/pipe_io.py +0 -0
  36. {pipelex_api-0.75.0 → pipelex_api-0.77.0}/pipelex_api/routes/pipelex/resolve.py +0 -0
  37. {pipelex_api-0.75.0 → pipelex_api-0.77.0}/pipelex_api/routes/pipelex/tools.py +0 -0
  38. {pipelex_api-0.75.0 → pipelex_api-0.77.0}/pipelex_api/routes/pipelex/utils.py +0 -0
  39. {pipelex_api-0.75.0 → pipelex_api-0.77.0}/pipelex_api/routes/pipelex/validate.py +0 -0
  40. {pipelex_api-0.75.0 → pipelex_api-0.77.0}/pipelex_api/routes/version.py +0 -0
  41. {pipelex_api-0.75.0 → pipelex_api-0.77.0}/pipelex_api/schemas/__init__.py +0 -0
  42. {pipelex_api-0.75.0 → pipelex_api-0.77.0}/pipelex_api/security.py +0 -0
  43. {pipelex_api-0.75.0 → pipelex_api-0.77.0}/pyproject.toml +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: pipelex-api
3
- Version: 0.75.0
3
+ Version: 0.77.0
4
4
  Summary: Pipelex API
5
5
  Project-URL: Homepage, https://pipelex.com
6
6
  Project-URL: Repository, https://github.com/Pipelex/pipelex
@@ -16,7 +16,7 @@ Classifier: Programming Language :: Python :: 3.13
16
16
  Classifier: Programming Language :: Python :: 3.14
17
17
  Requires-Python: <3.15,>=3.11
18
18
  Requires-Dist: fastapi<0.142,>=0.118.0
19
- Requires-Dist: pipelex[anthropic,bedrock,fal,google,google-genai,mistralai]==0.75.0
19
+ Requires-Dist: pipelex[anthropic,bedrock,fal,google,google-genai,mistralai]==0.77.0
20
20
  Requires-Dist: pyjwt>=2.10.1
21
21
  Requires-Dist: uvicorn>=0.37.0
22
22
  Provides-Extra: dev
@@ -0,0 +1,433 @@
1
+ """Materialize a caller-supplied method bundle into a temporary library directory.
2
+
3
+ A run request may carry the whole method — the `.mthds` bundle plus its PipeFunc
4
+ Python (`pipe_func.py`) and a `requirements.txt` — instead of only
5
+ the inline `mthds_contents` text. Two transport forms are accepted, exactly one
6
+ per request:
7
+
8
+ - `bundle_b64`: a base64-encoded zip archive of the bundle directory.
9
+ - `files`: a `{relative_path: text_content}` map (the zip's contents, unzipped).
10
+
11
+ The two are equivalent: `files` ≡ the zip's entries. This module decodes either
12
+ form, enforces the ingest guards (both transport forms are refused together; a
13
+ hard file-count and total-size ceiling; per-entry path-safety against absolute
14
+ paths and `..` traversal; a zip-bomb guard that bounds actual decompression),
15
+ writes the surviving files into a fresh temp directory, and hands that directory
16
+ back so the runner can load it via `library_dirs`.
17
+
18
+ A bundle may also ship the method packages it calls by address, each under
19
+ `.mthds/methods/<name>/` at its root with its `METHODS.toml` there, the layout the
20
+ standard names the local method cache. `partition_bundle_entries` sorts the entries
21
+ into the bundle's own `.mthds` files, its other files, and those packages, refusing
22
+ every other `.mthds` path with a `422 InvalidBundle` naming the entry, and two
23
+ packages declaring the same address with one naming both directories, and
24
+ `materialized_methods_dir` writes the packages into a temp directory of their own,
25
+ `<tmp>/<name>/…`, which the runner hands the engine as `methods_dirs`. In a sandbox-hosted
26
+ deployment the load path reads every `.py` as source text, never importing it:
27
+ it refuses a bundle whose Python declares a structure class
28
+ (`MethodStructuresRefusedError`, a 403) and captures the rest onto the crate for
29
+ the sandbox. The caller is responsible for the hosted-mode gate.
30
+
31
+ Nothing here imports or executes the bundle's Python — it only writes bytes to
32
+ disk. The caller cleans the directory up via the `materialized_bundle` context
33
+ manager's guaranteed teardown.
34
+ """
35
+
36
+ from __future__ import annotations
37
+
38
+ import base64
39
+ import binascii
40
+ import shutil
41
+ import tempfile
42
+ import zipfile
43
+ from contextlib import contextmanager
44
+ from io import BytesIO
45
+ from pathlib import Path, PurePosixPath
46
+ from typing import TYPE_CHECKING, NamedTuple, NoReturn
47
+
48
+ from mthds.package.discovery import MANIFEST_FILENAME
49
+ from mthds.package.exceptions import ManifestError
50
+ from mthds.package.manifest.parser import parse_methods_toml
51
+ from pipelex import log
52
+ from pydantic import ConfigDict
53
+ from pydantic.dataclasses import dataclass
54
+
55
+ from pipelex_api.error_types import ErrorType
56
+ from pipelex_api.errors import raise_bad_request, raise_payload_too_large, raise_validation_error
57
+ from pipelex_api.limits import MAX_BUNDLE_FILES, MAX_BUNDLE_TOTAL_BYTES
58
+
59
+ if TYPE_CHECKING:
60
+ from collections.abc import Generator
61
+
62
+
63
+ @dataclass(frozen=True, config=ConfigDict(arbitrary_types_allowed=True))
64
+ class MaterializedBundle:
65
+ """A bundle written to disk: the directory to load and the relpaths written."""
66
+
67
+ directory: Path
68
+ relpaths: tuple[str, ...]
69
+
70
+ @property
71
+ def has_python_sources(self) -> bool:
72
+ """True when the bundle ships any `.py` — the trigger for the hosted-mode gate."""
73
+ return any(relpath.endswith(".py") for relpath in self.relpaths)
74
+
75
+
76
+ def _safe_relpath(name: str) -> PurePosixPath:
77
+ """Validate one bundle entry name and return it as a normalized relative POSIX path.
78
+
79
+ Rejects anything that could escape the destination directory: absolute paths,
80
+ Windows drive/backslash forms (a bare drive prefix like `C:foo` has no slash or
81
+ backslash yet is drive-relative on Windows, so `:` is rejected outright), and any
82
+ `..` component. Directory-only entries (trailing slash) return an empty path and
83
+ are filtered by the caller.
84
+ """
85
+ if not name or name in {".", "./"}:
86
+ return PurePosixPath()
87
+ if "\\" in name:
88
+ msg = f"Bundle entry {name!r} uses backslashes; use forward-slash relative paths only"
89
+ raise_validation_error(message=msg, error_type=ErrorType.INVALID_BUNDLE)
90
+ if ":" in name:
91
+ msg = f"Bundle entry {name!r} contains ':' (a Windows drive/stream form); use plain relative paths only"
92
+ raise_validation_error(message=msg, error_type=ErrorType.INVALID_BUNDLE)
93
+ pure = PurePosixPath(name)
94
+ if pure.is_absolute():
95
+ msg = f"Bundle entry {name!r} is an absolute path; only relative paths are allowed"
96
+ raise_validation_error(message=msg, error_type=ErrorType.INVALID_BUNDLE)
97
+ if any(part == ".." for part in pure.parts):
98
+ msg = f"Bundle entry {name!r} escapes the bundle root via '..'; not allowed"
99
+ raise_validation_error(message=msg, error_type=ErrorType.INVALID_BUNDLE)
100
+ return pure
101
+
102
+
103
+ def _guard_count(count: int) -> None:
104
+ if count == 0:
105
+ raise_validation_error(message="Bundle is empty (no files)", error_type=ErrorType.INVALID_BUNDLE)
106
+ if count > MAX_BUNDLE_FILES:
107
+ raise_payload_too_large(message=f"Bundle exceeds the {MAX_BUNDLE_FILES}-file limit (got {count})")
108
+
109
+
110
+ def _guard_running_total(total_bytes: int) -> None:
111
+ if total_bytes > MAX_BUNDLE_TOTAL_BYTES:
112
+ raise_payload_too_large(message=f"Bundle exceeds the {MAX_BUNDLE_TOTAL_BYTES // 1024} KiB decompressed-size limit")
113
+
114
+
115
+ # Base64 inflates by 4/3; a zip is compressed, so a bundle whose DECOMPRESSED content is within
116
+ # the ceiling encodes to well under this. Bounding the base64 string BEFORE decoding stops a
117
+ # ~100 MiB request-body (the only other bound) from being expanded into ~75 MiB of heap just to
118
+ # be rejected later by the decompressed-size guard. Slack (+4) covers padding.
119
+ _MAX_BUNDLE_B64_CHARS = MAX_BUNDLE_TOTAL_BYTES * 4 // 3 + 4
120
+
121
+
122
+ def _entries_from_zip(bundle_b64: str) -> list[tuple[PurePosixPath, bytes]]:
123
+ """Decode a base64 zip and return its (safe relpath, bytes) file entries.
124
+
125
+ Zip-bomb guard: each member is read through a bounded stream so a lying
126
+ uncompressed-size header cannot force unbounded decompression — the running
127
+ total is checked against `MAX_BUNDLE_TOTAL_BYTES` as bytes are pulled. A cheap
128
+ length check on the still-encoded string runs FIRST, so an oversized payload is
129
+ refused before it is buffered into memory as decoded bytes.
130
+ """
131
+ if len(bundle_b64) > _MAX_BUNDLE_B64_CHARS:
132
+ raise_payload_too_large(message=f"bundle_b64 exceeds the {MAX_BUNDLE_TOTAL_BYTES // 1024} KiB compressed-size limit")
133
+ try:
134
+ raw = base64.b64decode(bundle_b64, validate=True)
135
+ except (binascii.Error, ValueError) as decode_error:
136
+ log.warning(f"bundle: invalid base64 ({decode_error})")
137
+ raise_bad_request(message="bundle_b64 is not valid base64", error_type=ErrorType.INVALID_BASE64)
138
+
139
+ try:
140
+ archive = zipfile.ZipFile(BytesIO(raw))
141
+ except zipfile.BadZipFile as zip_error:
142
+ log.warning(f"bundle: corrupt zip ({zip_error})")
143
+ raise_validation_error(message="bundle_b64 is not a valid zip archive", error_type=ErrorType.INVALID_BUNDLE)
144
+
145
+ entries: list[tuple[PurePosixPath, bytes]] = []
146
+ total_bytes = 0
147
+ with archive:
148
+ members = [info for info in archive.infolist() if not info.is_dir()]
149
+ _guard_count(len(members))
150
+ budget = MAX_BUNDLE_TOTAL_BYTES
151
+ for info in members:
152
+ relpath = _safe_relpath(info.filename)
153
+ if not relpath.parts:
154
+ continue
155
+ # Bounded read: pull at most (remaining budget + 1) bytes so a zip bomb whose header
156
+ # under-reports its size still cannot decompress past the ceiling.
157
+ with archive.open(info) as member:
158
+ data = member.read(budget + 1)
159
+ total_bytes += len(data)
160
+ _guard_running_total(total_bytes)
161
+ budget = MAX_BUNDLE_TOTAL_BYTES - total_bytes
162
+ entries.append((relpath, data))
163
+ return entries
164
+
165
+
166
+ def _entries_from_files(files: dict[str, str]) -> list[tuple[PurePosixPath, bytes]]:
167
+ r"""Validate a {relpath: text} map and return its (safe relpath, bytes) entries.
168
+
169
+ JSON can carry a lone surrogate as an escape (`"\ud800"`), which has no UTF-8 form, so an entry whose name or
170
+ content holds one is refused with a `422` naming it rather than failing later, when it is encoded or written.
171
+ """
172
+ _guard_count(len(files))
173
+ entries: list[tuple[PurePosixPath, bytes]] = []
174
+ total_bytes = 0
175
+ for name, content in files.items():
176
+ try:
177
+ name.encode("utf-8")
178
+ except UnicodeEncodeError:
179
+ msg = f"Bundle entry {name!r} has a name holding a lone surrogate, which is not valid Unicode text"
180
+ raise_validation_error(message=msg, error_type=ErrorType.INVALID_BUNDLE)
181
+ relpath = _safe_relpath(name)
182
+ if not relpath.parts:
183
+ msg = f"Bundle entry {name!r} has no filename"
184
+ raise_validation_error(message=msg, error_type=ErrorType.INVALID_BUNDLE)
185
+ try:
186
+ data = content.encode("utf-8")
187
+ except UnicodeEncodeError:
188
+ msg = f"Bundle entry {name!r} holds a lone surrogate, which is not valid Unicode text"
189
+ raise_validation_error(message=msg, error_type=ErrorType.INVALID_BUNDLE)
190
+ total_bytes += len(data)
191
+ _guard_running_total(total_bytes)
192
+ entries.append((relpath, data))
193
+ return entries
194
+
195
+
196
+ def _refuse_colliding_paths(entries: list[tuple[PurePosixPath, bytes]]) -> None:
197
+ """Refuse two entries of one path, and an entry that is also the directory of another.
198
+
199
+ Entry names are normalized (`a/./b` and `a//b` are `a/b`) and a zip may hold one member name twice, so two entries
200
+ can name one file: a check reading the first copy, as the shipped packages' manifest check does, would pass content
201
+ that differs from the last copy, which is the one written to disk and read by the engine. A file that another entry
202
+ is placed under could not be written at all.
203
+ """
204
+ paths: set[PurePosixPath] = set()
205
+ for relpath, _ in entries:
206
+ if relpath in paths:
207
+ msg = f"Bundle has more than one entry for the path '{relpath.as_posix()}'; each file may appear only once"
208
+ raise_validation_error(message=msg, error_type=ErrorType.INVALID_BUNDLE)
209
+ paths.add(relpath)
210
+ for relpath, _ in entries:
211
+ for parent in relpath.parents:
212
+ if parent in paths:
213
+ msg = f"Bundle entry '{parent.as_posix()}' is a file, and also the directory of the entry '{relpath.as_posix()}'"
214
+ raise_validation_error(message=msg, error_type=ErrorType.INVALID_BUNDLE)
215
+
216
+
217
+ class ParsedBundle(NamedTuple):
218
+ """A decoded-and-validated bundle held in memory, NOT yet written to disk.
219
+
220
+ Splitting parse from materialize lets the caller apply the sandbox-hosted gate
221
+ (which keys on `has_python_sources`) BEFORE any disk write — so a bundle destined
222
+ for a 403 never touches the filesystem. Entries are `(safe relpath, bytes)`.
223
+ """
224
+
225
+ entries: tuple[tuple[PurePosixPath, bytes], ...]
226
+
227
+ @property
228
+ def has_python_sources(self) -> bool:
229
+ """True when the bundle ships any `.py` — the trigger for the hosted-mode gate."""
230
+ return any(str(relpath).endswith(".py") for relpath, _ in self.entries)
231
+
232
+
233
+ def parse_bundle(*, bundle_b64: str | None, files: dict[str, str] | None) -> ParsedBundle:
234
+ """Decode + guard a bundle into an in-memory `ParsedBundle` (no disk writes).
235
+
236
+ Exactly one of `bundle_b64` / `files` must be supplied; supplying both is a
237
+ caller mistake (they are the same content in two forms) and is refused. All
238
+ ingest guards (base64, size, count, path-safety, zip-bomb, colliding paths) run
239
+ here, so the caller can inspect `has_python_sources` and reject BEFORE
240
+ materializing to disk.
241
+ """
242
+ if bundle_b64 is not None and files is not None:
243
+ msg = "Provide either bundle_b64 or files, not both"
244
+ raise_validation_error(message=msg, error_type=ErrorType.INVALID_BUNDLE)
245
+ if bundle_b64 is not None:
246
+ entries = _entries_from_zip(bundle_b64)
247
+ elif files is not None:
248
+ entries = _entries_from_files(files)
249
+ else:
250
+ msg = "No bundle supplied (bundle_b64 and files are both absent)"
251
+ raise_validation_error(message=msg, error_type=ErrorType.INVALID_BUNDLE)
252
+ _refuse_colliding_paths(entries)
253
+ return ParsedBundle(entries=tuple(entries))
254
+
255
+
256
+ # Where a bundle ships the method packages it calls: `.mthds/methods/<name>/`, at the bundle's root and nowhere else.
257
+ _MTHDS_DIR_NAME = ".mthds"
258
+ _METHODS_DIR_NAME = "methods"
259
+ _VENDORING_LAYOUT = (
260
+ "A method package shipped with the bundle goes under '.mthds/methods/<name>/' at the bundle's root, "
261
+ "with its manifest at '.mthds/methods/<name>/METHODS.toml'."
262
+ )
263
+
264
+
265
+ class VendoredMethodPackage(NamedTuple):
266
+ """A method package a bundle ships under `.mthds/methods/<name>/`, held in memory."""
267
+
268
+ name: str
269
+ """The package's directory name under `.mthds/methods/`."""
270
+
271
+ full_address: str
272
+ """The address its manifest declares, `address/name`, as a reference names it and a refusal names the package."""
273
+
274
+ entries: tuple[tuple[PurePosixPath, bytes], ...]
275
+ """Every file of the package, by its path relative to the package directory."""
276
+
277
+
278
+ class BundlePartition(NamedTuple):
279
+ """A bundle's entries sorted into its own `.mthds` files, its other files, and the packages it ships."""
280
+
281
+ mthds_entries: tuple[tuple[PurePosixPath, bytes], ...]
282
+ library_entries: tuple[tuple[PurePosixPath, bytes], ...]
283
+ vendored_packages: tuple[VendoredMethodPackage, ...]
284
+
285
+
286
+ def _refuse_entry(*, owner: str, relpath: PurePosixPath, reason: str) -> NoReturn:
287
+ msg = f"{owner} entry '{relpath.as_posix()}' {reason}. {_VENDORING_LAYOUT}"
288
+ raise_validation_error(message=msg, error_type=ErrorType.INVALID_BUNDLE)
289
+
290
+
291
+ def _vendored_package(*, owner: str, name: str, entries: list[tuple[PurePosixPath, bytes]]) -> VendoredMethodPackage:
292
+ """Check one shipped package's directory and read the address its manifest declares."""
293
+ package_label = f"'.mthds/methods/{name}/'"
294
+ if name.startswith("."):
295
+ msg = f"{owner} ships {package_label}, a hidden directory, which is never searched for a package. {_VENDORING_LAYOUT}"
296
+ raise_validation_error(message=msg, error_type=ErrorType.INVALID_BUNDLE)
297
+ manifest_bytes = next((data for relpath, data in entries if relpath == PurePosixPath(MANIFEST_FILENAME)), None)
298
+ if manifest_bytes is None:
299
+ msg = f"{owner} ships {package_label} without a {MANIFEST_FILENAME}, so no reference could find it. {_VENDORING_LAYOUT}"
300
+ raise_validation_error(message=msg, error_type=ErrorType.INVALID_BUNDLE)
301
+ manifest_path = f".mthds/methods/{name}/{MANIFEST_FILENAME}"
302
+ try:
303
+ manifest = parse_methods_toml(manifest_bytes.decode("utf-8"))
304
+ except UnicodeDecodeError:
305
+ msg = f"{owner} entry '{manifest_path}' is not valid UTF-8. {_VENDORING_LAYOUT}"
306
+ raise_validation_error(message=msg, error_type=ErrorType.INVALID_BUNDLE)
307
+ except ManifestError as exc:
308
+ msg = f"{owner} entry '{manifest_path}' is not a valid method package manifest: {exc.message} {_VENDORING_LAYOUT}"
309
+ raise_validation_error(message=msg, error_type=ErrorType.INVALID_BUNDLE)
310
+ # The name a reference matches, as the engine's discovery gives it: the manifest's, else the directory's.
311
+ package_name = manifest.name if manifest.name is not None else name
312
+ return VendoredMethodPackage(name=name, full_address=f"{manifest.address}/{package_name}", entries=tuple(entries))
313
+
314
+
315
+ def _refuse_shared_identities(*, owner: str, packages: tuple[VendoredMethodPackage, ...]) -> None:
316
+ """Refuse shipped packages a reference could not tell apart, rather than let the directory order pick one.
317
+
318
+ A reference is matched against a package's full address without regard to case, as the engine's lookup compares it,
319
+ so two packages whose full addresses differ only in case are one identity too.
320
+ """
321
+ by_identity: dict[str, list[VendoredMethodPackage]] = {}
322
+ for package in packages:
323
+ by_identity.setdefault(package.full_address.casefold(), []).append(package)
324
+ for sharing in by_identity.values():
325
+ if len(sharing) < 2:
326
+ continue
327
+ directories = ", ".join(f"'.mthds/methods/{package.name}/'" for package in sharing)
328
+ msg = (
329
+ f"{owner} ships several method packages declaring the address '{sharing[0].full_address}': {directories}. "
330
+ "A reference matches a package by its address without regard to case, so it could not tell them apart: "
331
+ "ship one package per address."
332
+ )
333
+ raise_validation_error(message=msg, error_type=ErrorType.INVALID_BUNDLE)
334
+
335
+
336
+ def partition_bundle_entries(entries: tuple[tuple[PurePosixPath, bytes], ...], *, owner: str = "Bundle") -> BundlePartition:
337
+ """Sort a bundle's entries into its own `.mthds` files, its other files, and the method packages it ships.
338
+
339
+ An entry under `.mthds/methods/<name>/`, whatever its extension, belongs to the shipped package `<name>`, which
340
+ leaves the bundle's own content and its library directory. Every other `.mthds` path component is refused with a
341
+ `422 InvalidBundle` naming the entry rather than dropped, since nothing could ever find what it holds: a file
342
+ elsewhere under `.mthds/`, a `.mthds/methods/` store below the root, a file directly under `.mthds/methods/`, and a
343
+ `.mthds/` inside a shipped package, whose own dependencies are not loaded. A shipped package must be a directory
344
+ whose name does not start with a dot and whose `METHODS.toml` parses, and the address that manifest declares is read
345
+ here, to name the package in a refusal. Two shipped packages declaring the same full address, compared without regard
346
+ to case as a reference is matched, are refused too, naming the address and both directories, since nothing but their
347
+ directory order would decide which one answers. Counting the bundle's own `.mthds` files is the caller's: a bundle
348
+ whose only `.mthds` files are shipped ones has no content of its own.
349
+
350
+ Args:
351
+ entries: The bundle's `(safe relpath, bytes)` entries.
352
+ owner: How a refusal names the bundle, for a method package fetched by address as for a request's bundle.
353
+
354
+ Returns:
355
+ The three groups, each in the bundle's entry order.
356
+ """
357
+ mthds_entries: list[tuple[PurePosixPath, bytes]] = []
358
+ library_entries: list[tuple[PurePosixPath, bytes]] = []
359
+ vendored_entries: dict[str, list[tuple[PurePosixPath, bytes]]] = {}
360
+ for relpath, data in entries:
361
+ parts = relpath.parts
362
+ if parts[0] == _MTHDS_DIR_NAME:
363
+ if len(parts) < 4 or parts[1] != _METHODS_DIR_NAME:
364
+ _refuse_entry(owner=owner, relpath=relpath, reason="is under '.mthds/' but not inside a shipped method package")
365
+ package_relpath = PurePosixPath(*parts[3:])
366
+ if _MTHDS_DIR_NAME in package_relpath.parts:
367
+ _refuse_entry(
368
+ owner=owner,
369
+ relpath=relpath,
370
+ reason="is a '.mthds/' directory inside a shipped method package, whose own dependencies are not loaded",
371
+ )
372
+ vendored_entries.setdefault(parts[2], []).append((package_relpath, data))
373
+ elif _MTHDS_DIR_NAME in parts:
374
+ _refuse_entry(owner=owner, relpath=relpath, reason="is under a '.mthds/' directory below the bundle's root, which is never searched")
375
+ elif relpath.suffix == ".mthds":
376
+ mthds_entries.append((relpath, data))
377
+ else:
378
+ library_entries.append((relpath, data))
379
+ vendored_packages = tuple(
380
+ _vendored_package(owner=owner, name=name, entries=package_entries) for name, package_entries in vendored_entries.items()
381
+ )
382
+ _refuse_shared_identities(owner=owner, packages=vendored_packages)
383
+ return BundlePartition(mthds_entries=tuple(mthds_entries), library_entries=tuple(library_entries), vendored_packages=vendored_packages)
384
+
385
+
386
+ @contextmanager
387
+ def materialize_parsed(parsed: ParsedBundle, *, prefix: str = "pipelex-bundle-") -> Generator[MaterializedBundle, None, None]:
388
+ """Write an already-parsed bundle into a fresh temp directory, cleaned up on exit.
389
+
390
+ The yielded `MaterializedBundle.directory` is safe to pass as a `library_dirs`
391
+ entry; it is removed when the context exits, on both the happy and error path.
392
+ """
393
+ directory = Path(tempfile.mkdtemp(prefix=prefix))
394
+ try:
395
+ root = directory.resolve()
396
+ relpaths: list[str] = []
397
+ for relpath, data in parsed.entries:
398
+ target = (directory / relpath).resolve()
399
+ # Defense-in-depth: even after per-part validation, confirm the resolved target stays
400
+ # under the temp root before writing (guards against symlink/edge normalization surprises).
401
+ if root != target and root not in target.parents:
402
+ msg = f"Bundle entry {str(relpath)!r} resolves outside the bundle root"
403
+ raise_validation_error(message=msg, error_type=ErrorType.INVALID_BUNDLE)
404
+ target.parent.mkdir(parents=True, exist_ok=True)
405
+ target.write_bytes(data)
406
+ relpaths.append(relpath.as_posix())
407
+ yield MaterializedBundle(directory=directory, relpaths=tuple(relpaths))
408
+ finally:
409
+ shutil.rmtree(directory, ignore_errors=True)
410
+
411
+
412
+ @contextmanager
413
+ def materialized_methods_dir(packages: tuple[VendoredMethodPackage, ...]) -> Generator[Path, None, None]:
414
+ """Write shipped method packages into a temp directory of their own, as `<tmp>/<name>/…`, cleaned up on exit.
415
+
416
+ The directory is laid out like `.mthds/methods/`, one directory per package, which is what the engine's
417
+ `methods_dirs` take, and its path holds no `.mthds/methods` a walk-up from a bundle could take for a store.
418
+ """
419
+ entries = tuple((PurePosixPath(package.name) / relpath, data) for package in packages for relpath, data in package.entries)
420
+ with materialize_parsed(ParsedBundle(entries=entries), prefix="pipelex-methods-") as materialized:
421
+ yield materialized.directory
422
+
423
+
424
+ @contextmanager
425
+ def materialized_bundle(*, bundle_b64: str | None, files: dict[str, str] | None) -> Generator[MaterializedBundle, None, None]:
426
+ """Parse AND materialize a bundle in one step (convenience for callers that don't gate).
427
+
428
+ Equivalent to `parse_bundle(...)` followed by `materialize_parsed(...)`; a caller
429
+ that must apply the sandbox-hosted gate before disk writes should use the two
430
+ steps directly instead.
431
+ """
432
+ with materialize_parsed(parse_bundle(bundle_b64=bundle_b64, files=files)) as bundle:
433
+ yield bundle
@@ -12,11 +12,15 @@ manifest-identity package location, the bounds, and the structures check all liv
12
12
  files travel as `mthds_contents` (paired with their real relative paths as
13
13
  `mthds_sources`, so diagnostics carry true per-file labels), and only the non-`.mthds`
14
14
  files are materialized into a temporary `library_dirs` entry — exactly the split the
15
- method-bundle transport uses, so a fetched package runs the same proven path.
15
+ method-bundle transport uses, so a fetched package runs the same proven path. The
16
+ packages it ships under its own `.mthds/methods/<name>/` are partitioned out of both, as
17
+ a bundle's are, into a temporary `methods_dirs` entry: the run routes hand it to the
18
+ engine, and `/validate` does not, resolving such a dependency through the installed store
19
+ or fetch-on-miss.
16
20
  - The tooling routes (`/resolve`, `/codegen`, `/pipe-io`) use
17
- :func:`fetch_method_mthds_files`: only the `.mthds` files, as `files[]` items, paired with
18
- the manifest's `main_pipe` so the per-pipe route defaults its selector exactly as a run
19
- does. No Python ever loads there, so the execution-locus gate does not apply.
21
+ :func:`fetch_method_mthds_files`: only the package's own `.mthds` files, as `files[]` items,
22
+ paired with the manifest's `main_pipe` so the per-pipe route defaults its selector exactly
23
+ as a run does. No Python ever loads there, so the execution-locus gate does not apply.
20
24
 
21
25
  The security gate (packaging invariant 7 — execution locus decides): `.mthds` content is
22
26
  data, always acceptable. On a deployment that is NOT sandbox-hosted, a fetched package
@@ -34,9 +38,9 @@ can never race a running pipeline.
34
38
  """
35
39
 
36
40
  from collections.abc import Generator
37
- from contextlib import contextmanager
41
+ from contextlib import ExitStack, contextmanager
38
42
  from pathlib import Path, PurePosixPath
39
- from typing import NamedTuple
43
+ from typing import NamedTuple, NoReturn
40
44
 
41
45
  from mthds.package.discovery import MANIFEST_FILENAME
42
46
  from pipelex import log
@@ -46,7 +50,7 @@ from pipelex.methods.method_ref import parse_method_ref
46
50
  from pipelex.methods.structures_check import ensure_no_structured_content_python
47
51
  from pydantic import BaseModel, ConfigDict, Field
48
52
 
49
- from pipelex_api.bundle import ParsedBundle, materialize_parsed
53
+ from pipelex_api.bundle import BundlePartition, ParsedBundle, materialize_parsed, materialized_methods_dir, partition_bundle_entries
50
54
  from pipelex_api.error_types import ErrorType
51
55
  from pipelex_api.errors import raise_forbidden, raise_validation_error
52
56
  from pipelex_api.limits import MAX_MTHDS_FILE_BYTES
@@ -64,6 +68,9 @@ class FetchedMethodSource(BaseModel):
64
68
  mthds_contents: list[str]
65
69
  mthds_sources: list[str]
66
70
  library_dirs: list[str] | None = None
71
+ methods_dirs: list[Path] | None = Field(
72
+ default=None, description="The methods directory holding the packages the package ships under its own `.mthds/methods/`."
73
+ )
67
74
  main_pipe: str | None = Field(default=None, description="The manifest's declared entry pipe; a request `pipe_code` overrides it.")
68
75
  provenance: MethodProvenance
69
76
 
@@ -106,17 +113,36 @@ def _package_files(package: FetchedMethodPackage) -> list[Path]:
106
113
  return files
107
114
 
108
115
 
109
- def _read_mthds_text(file_path: Path, *, relative: str, package_address: str) -> str:
116
+ def _decode_mthds_text(data: bytes, *, relative: str, package_address: str) -> str:
110
117
  try:
111
- content = file_path.read_text(encoding="utf-8")
118
+ content = data.decode("utf-8")
112
119
  except UnicodeDecodeError:
113
120
  raise_validation_error(message=f"File '{relative}' in method package '{package_address}' is not valid UTF-8.")
114
- if len(content.encode("utf-8")) > MAX_MTHDS_FILE_BYTES:
121
+ if len(data) > MAX_MTHDS_FILE_BYTES:
115
122
  msg = f"File '{relative}' in method package '{package_address}' exceeds the {MAX_MTHDS_FILE_BYTES // 1024} KiB per-file limit."
116
123
  raise_validation_error(message=msg)
117
124
  return content
118
125
 
119
126
 
127
+ def _partition_package(package: FetchedMethodPackage, *, files: list[Path]) -> BundlePartition:
128
+ """Sort a fetched package's files as a bundle's are: its own `.mthds`, its other files, and the packages it ships.
129
+
130
+ The refusals are a bundle's too: a `.mthds` path no reference could find is a `422` naming the file.
131
+ """
132
+ entries = tuple((PurePosixPath(file_path.relative_to(package.package_dir).as_posix()), file_path.read_bytes()) for file_path in files)
133
+ return partition_bundle_entries(entries, owner=f"Method package '{package.full_address}'")
134
+
135
+
136
+ def _refuse_no_own_mthds(package: FetchedMethodPackage, *, partition: BundlePartition) -> NoReturn:
137
+ msg = f"Method package '{package.full_address}' contains no .mthds file."
138
+ if partition.vendored_packages:
139
+ msg = (
140
+ f"Method package '{package.full_address}' contains no .mthds file of its own: "
141
+ "the files under '.mthds/methods/' are the packages it ships."
142
+ )
143
+ raise_validation_error(message=msg)
144
+
145
+
120
146
  def _apply_execution_locus_gate(package: FetchedMethodPackage, *, python_relpaths: list[str]) -> None:
121
147
  """Refuse Python that would execute where it must not (see the module docstring)."""
122
148
  if not python_relpaths:
@@ -131,10 +157,11 @@ def _apply_execution_locus_gate(package: FetchedMethodPackage, *, python_relpath
131
157
  def fetched_method_source(method_ref: str) -> Generator[FetchedMethodSource, None, None]:
132
158
  """Fetch a `method_ref`'s package and shape it for the run/validate path.
133
159
 
134
- Yields the package's `.mthds` files as `(mthds_contents, mthds_sources)` pairs and its
160
+ Yields the package's `.mthds` files as `(mthds_contents, mthds_sources)` pairs, its
135
161
  non-`.mthds` files (PipeFunc `.py`, `requirements.txt`, …) materialized into a temporary
136
- `library_dirs` entry, cleaned up on exit. The execution-locus gate runs before anything
137
- touches disk.
162
+ `library_dirs` entry, and the packages it ships under its own `.mthds/methods/<name>/`
163
+ materialized into a temporary `methods_dirs` entry, each cleaned up on exit. The
164
+ execution-locus gate runs before anything touches disk.
138
165
 
139
166
  Args:
140
167
  method_ref: The raw `method_ref` string from the request.
@@ -148,37 +175,35 @@ def fetched_method_source(method_ref: str) -> Generator[FetchedMethodSource, Non
148
175
  package = _fetch_package(method_ref)
149
176
  files = _package_files(package)
150
177
  python_relpaths = [file_path.relative_to(package.package_dir).as_posix() for file_path in files if file_path.suffix == ".py"]
178
+ # The whole package, the packages it ships included, so a structure class anywhere in it is refused before anything is copied.
151
179
  _apply_execution_locus_gate(package, python_relpaths=python_relpaths)
152
180
 
181
+ partition = _partition_package(package, files=files)
153
182
  mthds_contents: list[str] = []
154
183
  mthds_sources: list[str] = []
155
- other_entries: list[tuple[PurePosixPath, bytes]] = []
156
- for file_path in files:
157
- relative = file_path.relative_to(package.package_dir).as_posix()
158
- if file_path.suffix == ".mthds":
159
- mthds_contents.append(_read_mthds_text(file_path, relative=relative, package_address=package.full_address))
160
- mthds_sources.append(relative)
161
- elif file_path.name != MANIFEST_FILENAME:
162
- # The manifest is already consumed (identity + `main_pipe`); materializing it into
163
- # the library dir would hand the local loader a package boundary it must not see.
164
- other_entries.append((PurePosixPath(relative), file_path.read_bytes()))
184
+ for relpath, data in partition.mthds_entries:
185
+ relative = relpath.as_posix()
186
+ mthds_contents.append(_decode_mthds_text(data, relative=relative, package_address=package.full_address))
187
+ mthds_sources.append(relative)
165
188
  if not mthds_contents:
166
- raise_validation_error(message=f"Method package '{package.full_address}' contains no .mthds file.")
167
-
168
- if not other_entries:
189
+ _refuse_no_own_mthds(package, partition=partition)
190
+ # The manifest is already consumed (identity + `main_pipe`); materializing it into the library dir would hand the
191
+ # local loader a package boundary it must not see.
192
+ other_entries = tuple((relpath, data) for relpath, data in partition.library_entries if relpath.name != MANIFEST_FILENAME)
193
+
194
+ with ExitStack() as stack:
195
+ library_dirs: list[str] | None = None
196
+ if other_entries:
197
+ library_bundle = stack.enter_context(materialize_parsed(ParsedBundle(entries=other_entries)))
198
+ library_dirs = [str(library_bundle.directory)]
199
+ methods_dirs: list[Path] | None = None
200
+ if partition.vendored_packages:
201
+ methods_dirs = [stack.enter_context(materialized_methods_dir(partition.vendored_packages))]
169
202
  yield FetchedMethodSource(
170
203
  mthds_contents=mthds_contents,
171
204
  mthds_sources=mthds_sources,
172
- library_dirs=None,
173
- main_pipe=package.manifest.main_pipe,
174
- provenance=package.provenance,
175
- )
176
- return
177
- with materialize_parsed(ParsedBundle(entries=tuple(other_entries))) as bundle:
178
- yield FetchedMethodSource(
179
- mthds_contents=mthds_contents,
180
- mthds_sources=mthds_sources,
181
- library_dirs=[str(bundle.directory)],
205
+ library_dirs=library_dirs,
206
+ methods_dirs=methods_dirs,
182
207
  main_pipe=package.manifest.main_pipe,
183
208
  provenance=package.provenance,
184
209
  )
@@ -188,9 +213,11 @@ def fetch_method_mthds_files(method_ref: str) -> FetchedMthdsFiles:
188
213
  """Fetch a `method_ref`'s package and return its `.mthds` files as `files[]` items, plus its `main_pipe`.
189
214
 
190
215
  The tooling-route shape: each item pairs the file's content with its real relative path
191
- as `source`, so crate provenance and diagnostics carry true per-file labels. Only
192
- `.mthds` data travels — the package's Python (if any) never loads on these routes, so
193
- the execution-locus gate does not apply here. The manifest's `main_pipe` rides beside the
216
+ as `source`, so crate provenance and diagnostics carry true per-file labels. Only the
217
+ package's own `.mthds` data travels, never that of a package it ships under its
218
+ `.mthds/methods/`, since these routes resolve no address-based dependency. The package's
219
+ Python (if any) never loads on these routes, so the execution-locus gate does not apply
220
+ here. The manifest's `main_pipe` rides beside the
194
221
  files so a per-pipe projection can default its selector the way a run does — the manifest
195
222
  is the package author's declaration of the entry pipe, and dropping it here would make
196
223
  `main_pipe` buy them nothing on the tooling routes.
@@ -203,13 +230,12 @@ def fetch_method_mthds_files(method_ref: str) -> FetchedMthdsFiles:
203
230
  ApiError: 422 for a package with no `.mthds` file or one this server cannot accept.
204
231
  """
205
232
  package = _fetch_package(method_ref)
233
+ partition = _partition_package(package, files=_package_files(package))
206
234
  items: list[MthdsFileItem] = []
207
- for file_path in _package_files(package):
208
- if file_path.suffix != ".mthds":
209
- continue
210
- relative = file_path.relative_to(package.package_dir).as_posix()
211
- content = _read_mthds_text(file_path, relative=relative, package_address=package.full_address)
235
+ for relpath, data in partition.mthds_entries:
236
+ relative = relpath.as_posix()
237
+ content = _decode_mthds_text(data, relative=relative, package_address=package.full_address)
212
238
  items.append(MthdsFileItem(content=content, source=relative))
213
239
  if not items:
214
- raise_validation_error(message=f"Method package '{package.full_address}' contains no .mthds file.")
240
+ _refuse_no_own_mthds(package, partition=partition)
215
241
  return FetchedMthdsFiles(files=items, main_pipe=package.manifest.main_pipe)
@@ -2,8 +2,8 @@ from __future__ import annotations
2
2
 
3
3
  import hashlib
4
4
  import hmac
5
- from contextlib import contextmanager
6
- from pathlib import Path, PurePosixPath
5
+ from contextlib import ExitStack, contextmanager
6
+ from pathlib import Path
7
7
  from typing import TYPE_CHECKING, Annotated, Any, NamedTuple, cast
8
8
 
9
9
  from fastapi import APIRouter, Depends, Request
@@ -11,6 +11,7 @@ from fastapi.responses import JSONResponse
11
11
  from mthds.protocol.exceptions import PipelineRequestError
12
12
  from pipelex.config import get_config, is_pipe_func_sandbox_hosted
13
13
  from pipelex.core.pipes.pipe_output import PipeOutput
14
+ from pipelex.methods.structures_check import ensure_no_structured_content_python
14
15
  from pipelex.pipe_run.delivery_assignment import DeliveryAssignment, StorageTarget, WebhookTarget
15
16
  from pipelex.pipe_run.pipe_run_protocol import PipeRunProtocol
16
17
  from pipelex.pipeline.pipeline_response import PipelexRunResultExecute, PipelexRunResultStart, RunState
@@ -26,7 +27,14 @@ from pydantic import ValidationError
26
27
  from typing_extensions import override
27
28
 
28
29
  from pipelex_api.api_config import get_api_config, resolve_orchestration_mode
29
- from pipelex_api.bundle import ParsedBundle, materialize_parsed, parse_bundle
30
+ from pipelex_api.bundle import (
31
+ ParsedBundle,
32
+ VendoredMethodPackage,
33
+ materialize_parsed,
34
+ materialized_methods_dir,
35
+ parse_bundle,
36
+ partition_bundle_entries,
37
+ )
30
38
  from pipelex_api.error_types import ErrorType
31
39
  from pipelex_api.errors import raise_bad_request, raise_forbidden, raise_validation_error
32
40
  from pipelex_api.json_body import decode_json_body
@@ -407,6 +415,8 @@ class ApiRunner(PipelexMTHDSProtocol):
407
415
  # here, so a group left out of this call is dropped with no error
408
416
  # and the run's spans lose their groups while the ack still says 202.
409
417
  extras=self.extras,
418
+ # The packages the request ships, which the base `execute` threads too.
419
+ methods_dirs=self.methods_dirs,
410
420
  pipeline_run_id=pipeline_run_id,
411
421
  request_id=request_id,
412
422
  )
@@ -685,31 +695,58 @@ async def _parse_request(request: Request) -> tuple[RunRequest, PipelineApiExtra
685
695
  return run_request, extras
686
696
 
687
697
 
698
+ class _BundleRunSource(NamedTuple):
699
+ """What a request's run source resolves to, before the routes add its entry pipe and provenance."""
700
+
701
+ mthds_contents: list[str] | None
702
+ library_dirs: list[str] | None
703
+ methods_dirs: list[Path] | None
704
+
705
+
706
+ def _ensure_vendored_packages_hold_no_structures(*, methods_dir: Path, packages: tuple[VendoredMethodPackage, ...]) -> None:
707
+ """Refuse a shipped package whose Python declares a structure class, as a fetched package is on this deployment.
708
+
709
+ The library load refuses the bundle's own structure classes, but never reads a shipped package's Python, which is
710
+ not in a library directory. Each package is named by the address its manifest declares, and each file by its path
711
+ inside the package, never by the temporary directory.
712
+ """
713
+ for package in packages:
714
+ if any(relpath.suffix == ".py" for relpath, _ in package.entries):
715
+ ensure_no_structured_content_python(package_dir=methods_dir / package.name, package_address=package.full_address)
716
+
717
+
688
718
  @contextmanager
689
- def _bundle_run_source(run_request: RunRequest) -> Generator[tuple[list[str] | None, list[str] | None], None, None]:
690
- """Resolve a run's `(mthds_contents, library_dirs)` from the request.
719
+ def _bundle_run_source(run_request: RunRequest) -> Generator[_BundleRunSource, None, None]:
720
+ """Resolve a run's `(mthds_contents, library_dirs, methods_dirs)` from the request.
691
721
 
692
- A method bundle KEEPS the proven run path rather than replacing it: its `.mthds`
722
+ A method bundle KEEPS the proven run path rather than replacing it: its own `.mthds`
693
723
  text travels as `mthds_contents` — so `main_pipe` resolves exactly as it does for a
694
- plain (non-bundle) run — and ONLY the non-`.mthds` files (custom PipeFunc `.py`,
724
+ plain (non-bundle) run — and its other files (custom PipeFunc `.py`,
695
725
  `requirements.txt`) are materialized into a temp `library_dirs` entry, where the
696
726
  load path reads them without importing them. This mirrors "a normal run, plus the Python", instead of
697
727
  handing the engine a bare directory with no `mthds_contents` (which never resolves
698
728
  `main_pipe`, since that is only derived from `mthds_contents`).
699
729
 
700
- No bundle → yields the request's own `mthds_contents` and no library dir (the
701
- classic path, unchanged). Bundle with no non-`.mthds` files → yields the `.mthds`
702
- texts and no library dir. The temp dir (when created) is cleaned up on exit.
703
-
704
- Security gate (decision 5): a bundle that ships custom Python (`.py`) is only
705
- honored on a sandbox-hosted deployment, where the load path reads the source
706
- without importing it and refuses, with a 403 `MethodStructuresRefusedError`, a
707
- bundle whose Python declares a structure class; PipeFunc source is captured for
708
- the sandbox. On a non-hosted deployment, running that code would import it
709
- in-process — refused with a 403 rather than executing untrusted code.
730
+ The method packages the bundle ships under `.mthds/methods/<name>/` belong to neither:
731
+ every file of theirs is written into a temp directory of its own, as `<tmp>/<name>/…`,
732
+ which the engine searches first for an address-based reference and never writes, so a
733
+ shipped package wins over an installed copy and ends with the request. The other `.mthds`
734
+ paths are refused with a `422` naming the entry (`partition_bundle_entries`), and so are two
735
+ shipped packages declaring the same address and a bundle whose only `.mthds` files are shipped ones.
736
+
737
+ No bundle → yields the request's own `mthds_contents` and no directory (the classic path,
738
+ unchanged). Each temp dir is created only when it has files, and cleaned up on exit.
739
+
740
+ Security gate (decision 5): a bundle that ships custom Python (`.py`), in a shipped package
741
+ or not, is only honored on a sandbox-hosted deployment, where the load path reads the bundle's
742
+ own source without importing it and refuses, with a 403 `MethodStructuresRefusedError`, a
743
+ bundle whose Python declares a structure class; PipeFunc source is captured for the sandbox.
744
+ A shipped package's Python is checked the same way, as a fetched package's is, and never runs.
745
+ On a non-hosted deployment, running that code would import it in-process — refused with a 403
746
+ rather than executing untrusted code.
710
747
  """
711
748
  if run_request.bundle_b64 is None and run_request.files is None:
712
- yield run_request.mthds_contents, None
749
+ yield _BundleRunSource(mthds_contents=run_request.mthds_contents, library_dirs=None, methods_dirs=None)
713
750
  return
714
751
  # Parse + guard in memory FIRST, then apply the sandbox-hosted gate BEFORE any disk write —
715
752
  # a bundle destined for a 403 on a non-hosted deployment never touches the filesystem.
@@ -717,25 +754,36 @@ def _bundle_run_source(run_request: RunRequest) -> Generator[tuple[list[str] | N
717
754
  if parsed.has_python_sources and not is_pipe_func_sandbox_hosted():
718
755
  msg = "This bundle ships custom Python (.py); running it requires a sandbox-hosted deployment."
719
756
  raise_forbidden(message=msg, error_type=ErrorType.CUSTOM_CODE_REQUIRES_SANDBOX)
720
- # Split: `.mthds` text → `mthds_contents` (the proven main_pipe path); everything else
721
- # (`.py`, `requirements.txt`) → a temp `library_dirs` entry the load path source-captures.
757
+ # Split: the bundle's own `.mthds` text → `mthds_contents` (the proven main_pipe path); its other files
758
+ # (`.py`, `requirements.txt`) → a temp `library_dirs` entry the load path source-captures; the packages it
759
+ # ships → a temp methods directory.
760
+ partition = partition_bundle_entries(parsed.entries)
722
761
  mthds_contents: list[str] = []
723
- other_entries: list[tuple[PurePosixPath, bytes]] = []
724
- for relpath, content in parsed.entries:
725
- if str(relpath).endswith(".mthds"):
726
- try:
727
- mthds_contents.append(content.decode("utf-8"))
728
- except UnicodeDecodeError:
729
- raise_validation_error(message=f"Bundle .mthds file '{relpath}' is not valid UTF-8.", error_type=ErrorType.INVALID_BUNDLE)
730
- else:
731
- other_entries.append((relpath, content))
762
+ for relpath, content in partition.mthds_entries:
763
+ try:
764
+ mthds_contents.append(content.decode("utf-8"))
765
+ except UnicodeDecodeError:
766
+ raise_validation_error(message=f"Bundle .mthds file '{relpath}' is not valid UTF-8.", error_type=ErrorType.INVALID_BUNDLE)
732
767
  if not mthds_contents:
733
- raise_validation_error(message="Method bundle contains no .mthds file.", error_type=ErrorType.INVALID_BUNDLE)
734
- if not other_entries:
735
- yield mthds_contents, None
736
- return
737
- with materialize_parsed(ParsedBundle(entries=tuple(other_entries))) as bundle:
738
- yield mthds_contents, [str(bundle.directory)]
768
+ msg = "Method bundle contains no .mthds file."
769
+ if partition.vendored_packages:
770
+ msg = (
771
+ "Method bundle contains no .mthds file of its own: the files under '.mthds/methods/<name>/' are the method "
772
+ "packages it ships, and the bundle's own .mthds files go outside '.mthds/'."
773
+ )
774
+ raise_validation_error(message=msg, error_type=ErrorType.INVALID_BUNDLE)
775
+ with ExitStack() as stack:
776
+ library_dirs: list[str] | None = None
777
+ if partition.library_entries:
778
+ library_bundle = stack.enter_context(materialize_parsed(ParsedBundle(entries=partition.library_entries)))
779
+ library_dirs = [str(library_bundle.directory)]
780
+ methods_dirs: list[Path] | None = None
781
+ if partition.vendored_packages:
782
+ methods_dir = stack.enter_context(materialized_methods_dir(partition.vendored_packages))
783
+ if is_pipe_func_sandbox_hosted():
784
+ _ensure_vendored_packages_hold_no_structures(methods_dir=methods_dir, packages=partition.vendored_packages)
785
+ methods_dirs = [methods_dir]
786
+ yield _BundleRunSource(mthds_contents=mthds_contents, library_dirs=library_dirs, methods_dirs=methods_dirs)
739
787
 
740
788
 
741
789
  class _ResolvedRunSource(NamedTuple):
@@ -743,6 +791,7 @@ class _ResolvedRunSource(NamedTuple):
743
791
 
744
792
  mthds_contents: list[str] | None
745
793
  library_dirs: list[str] | None
794
+ methods_dirs: list[Path] | None
746
795
  pipe_code: str | None
747
796
  method_provenance: MethodProvenance | None
748
797
 
@@ -753,7 +802,8 @@ def _run_source(run_request: RunRequest) -> Generator[_ResolvedRunSource, None,
753
802
 
754
803
  A `method_ref` resolves through `pipelex_api.method_source.fetched_method_source` (fetch → locate →
755
804
  execution-locus gate → materialize) into exactly the shape a bundle produces — `.mthds` text
756
- as `mthds_contents`, non-`.mthds` files in a temp `library_dirs` entry — so the engine runs
805
+ as `mthds_contents`, non-`.mthds` files in a temp `library_dirs` entry, the packages the package
806
+ ships under its own `.mthds/methods/` in a temp `methods_dirs` entry — so the engine runs
757
807
  the same proven path. The entry pipe defaults to the fetched manifest's `main_pipe`; a
758
808
  request `pipe_code` overrides it. Provenance `(address, tag, commit_sha)` rides back so the
759
809
  routes can put it on the response. The other two forms delegate to `_bundle_run_source`,
@@ -764,14 +814,16 @@ def _run_source(run_request: RunRequest) -> Generator[_ResolvedRunSource, None,
764
814
  yield _ResolvedRunSource(
765
815
  mthds_contents=fetched.mthds_contents,
766
816
  library_dirs=fetched.library_dirs,
817
+ methods_dirs=fetched.methods_dirs,
767
818
  pipe_code=run_request.pipe_code or fetched.main_pipe,
768
819
  method_provenance=fetched.provenance,
769
820
  )
770
821
  return
771
- with _bundle_run_source(run_request) as (mthds_contents, library_dirs):
822
+ with _bundle_run_source(run_request) as bundle_source:
772
823
  yield _ResolvedRunSource(
773
- mthds_contents=mthds_contents,
774
- library_dirs=library_dirs,
824
+ mthds_contents=bundle_source.mthds_contents,
825
+ library_dirs=bundle_source.library_dirs,
826
+ methods_dirs=bundle_source.methods_dirs,
775
827
  pipe_code=run_request.pipe_code,
776
828
  method_provenance=None,
777
829
  )
@@ -825,6 +877,7 @@ async def execute(request: Request) -> JSONResponse:
825
877
  read_scope=scopes.read_scope,
826
878
  extras=extras.analytics_groups,
827
879
  library_dirs=source.library_dirs,
880
+ methods_dirs=source.methods_dirs,
828
881
  )
829
882
  response = await runner.execute(
830
883
  pipe_code=source.pipe_code,
@@ -916,6 +969,7 @@ async def start(
916
969
  read_scope=scopes.read_scope,
917
970
  extras=extras.analytics_groups,
918
971
  library_dirs=source.library_dirs,
972
+ methods_dirs=source.methods_dirs,
919
973
  )
920
974
  start_result = await runner.start(
921
975
  pipe_code=source.pipe_code,
@@ -71,7 +71,10 @@ class RunRequest(BaseModel):
71
71
  bundle_b64: PIPELEX-API EXTENSION — base64-encoded zip of the whole method
72
72
  bundle (`.mthds` + `.py` + `structures/*.py` + `requirements.txt`). Lets a
73
73
  caller ship custom PipeFunc Python alongside the method; materialized into a
74
- temporary library directory before the run. Mutually exclusive with `files`.
74
+ temporary library directory before the run. The method packages the bundle
75
+ calls by address may travel with it under `.mthds/methods/<name>/`, each with
76
+ its `METHODS.toml`, and are searched before the runner's installed copies.
77
+ Mutually exclusive with `files`.
75
78
  files: PIPELEX-API EXTENSION — the bundle as a `{relative_path: text}` map (the
76
79
  unzipped equivalent of `bundle_b64`). Mutually exclusive with `bundle_b64`.
77
80
  method_ref: PIPELEX-API EXTENSION — run a published method by address instead of
@@ -95,6 +98,8 @@ class RunRequest(BaseModel):
95
98
  "PIPELEX-API EXTENSION (not part of the MTHDS Protocol) — base64-encoded zip of the whole "
96
99
  "method bundle (`.mthds` + `.py` + `structures/*.py` + `requirements.txt`), materialized into a "
97
100
  "temporary library directory before the run so custom PipeFunc Python travels with the method. "
101
+ "The method packages the bundle calls by address may travel with it under `.mthds/methods/<name>/`, "
102
+ "each with its `METHODS.toml`; they are searched before the runner's installed copies, for this run alone. "
98
103
  "Mutually exclusive with `files`. Custom `.py` is only honored on a sandbox-hosted deployment."
99
104
  ),
100
105
  )
@@ -1,243 +0,0 @@
1
- """Materialize a caller-supplied method bundle into a temporary library directory.
2
-
3
- A run request may carry the whole method — the `.mthds` bundle plus its PipeFunc
4
- Python (`pipe_func.py`) and a `requirements.txt` — instead of only
5
- the inline `mthds_contents` text. Two transport forms are accepted, exactly one
6
- per request:
7
-
8
- - `bundle_b64`: a base64-encoded zip archive of the bundle directory.
9
- - `files`: a `{relative_path: text_content}` map (the zip's contents, unzipped).
10
-
11
- The two are equivalent: `files` ≡ the zip's entries. This module decodes either
12
- form, enforces the ingest guards (both transport forms are refused together; a
13
- hard file-count and total-size ceiling; per-entry path-safety against absolute
14
- paths and `..` traversal; a zip-bomb guard that bounds actual decompression),
15
- writes the surviving files into a fresh temp directory, and hands that directory
16
- back so the runner can load it via `library_dirs`. In a sandbox-hosted
17
- deployment the load path reads every `.py` as source text, never importing it:
18
- it refuses a bundle whose Python declares a structure class
19
- (`MethodStructuresRefusedError`, a 403) and captures the rest onto the crate for
20
- the sandbox. The caller is responsible for the hosted-mode gate.
21
-
22
- Nothing here imports or executes the bundle's Python — it only writes bytes to
23
- disk. The caller cleans the directory up via the `materialized_bundle` context
24
- manager's guaranteed teardown.
25
- """
26
-
27
- from __future__ import annotations
28
-
29
- import base64
30
- import binascii
31
- import shutil
32
- import tempfile
33
- import zipfile
34
- from contextlib import contextmanager
35
- from io import BytesIO
36
- from pathlib import Path, PurePosixPath
37
- from typing import TYPE_CHECKING, NamedTuple
38
-
39
- from pipelex import log
40
- from pydantic import ConfigDict
41
- from pydantic.dataclasses import dataclass
42
-
43
- from pipelex_api.error_types import ErrorType
44
- from pipelex_api.errors import raise_bad_request, raise_payload_too_large, raise_validation_error
45
- from pipelex_api.limits import MAX_BUNDLE_FILES, MAX_BUNDLE_TOTAL_BYTES
46
-
47
- if TYPE_CHECKING:
48
- from collections.abc import Generator
49
-
50
-
51
- @dataclass(frozen=True, config=ConfigDict(arbitrary_types_allowed=True))
52
- class MaterializedBundle:
53
- """A bundle written to disk: the directory to load and the relpaths written."""
54
-
55
- directory: Path
56
- relpaths: tuple[str, ...]
57
-
58
- @property
59
- def has_python_sources(self) -> bool:
60
- """True when the bundle ships any `.py` — the trigger for the hosted-mode gate."""
61
- return any(relpath.endswith(".py") for relpath in self.relpaths)
62
-
63
-
64
- def _safe_relpath(name: str) -> PurePosixPath:
65
- """Validate one bundle entry name and return it as a normalized relative POSIX path.
66
-
67
- Rejects anything that could escape the destination directory: absolute paths,
68
- Windows drive/backslash forms (a bare drive prefix like `C:foo` has no slash or
69
- backslash yet is drive-relative on Windows, so `:` is rejected outright), and any
70
- `..` component. Directory-only entries (trailing slash) return an empty path and
71
- are filtered by the caller.
72
- """
73
- if not name or name in {".", "./"}:
74
- return PurePosixPath()
75
- if "\\" in name:
76
- msg = f"Bundle entry {name!r} uses backslashes; use forward-slash relative paths only"
77
- raise_validation_error(message=msg, error_type=ErrorType.INVALID_BUNDLE)
78
- if ":" in name:
79
- msg = f"Bundle entry {name!r} contains ':' (a Windows drive/stream form); use plain relative paths only"
80
- raise_validation_error(message=msg, error_type=ErrorType.INVALID_BUNDLE)
81
- pure = PurePosixPath(name)
82
- if pure.is_absolute():
83
- msg = f"Bundle entry {name!r} is an absolute path; only relative paths are allowed"
84
- raise_validation_error(message=msg, error_type=ErrorType.INVALID_BUNDLE)
85
- if any(part == ".." for part in pure.parts):
86
- msg = f"Bundle entry {name!r} escapes the bundle root via '..'; not allowed"
87
- raise_validation_error(message=msg, error_type=ErrorType.INVALID_BUNDLE)
88
- return pure
89
-
90
-
91
- def _guard_count(count: int) -> None:
92
- if count == 0:
93
- raise_validation_error(message="Bundle is empty (no files)", error_type=ErrorType.INVALID_BUNDLE)
94
- if count > MAX_BUNDLE_FILES:
95
- raise_payload_too_large(message=f"Bundle exceeds the {MAX_BUNDLE_FILES}-file limit (got {count})")
96
-
97
-
98
- def _guard_running_total(total_bytes: int) -> None:
99
- if total_bytes > MAX_BUNDLE_TOTAL_BYTES:
100
- raise_payload_too_large(message=f"Bundle exceeds the {MAX_BUNDLE_TOTAL_BYTES // 1024} KiB decompressed-size limit")
101
-
102
-
103
- # Base64 inflates by 4/3; a zip is compressed, so a bundle whose DECOMPRESSED content is within
104
- # the ceiling encodes to well under this. Bounding the base64 string BEFORE decoding stops a
105
- # ~100 MiB request-body (the only other bound) from being expanded into ~75 MiB of heap just to
106
- # be rejected later by the decompressed-size guard. Slack (+4) covers padding.
107
- _MAX_BUNDLE_B64_CHARS = MAX_BUNDLE_TOTAL_BYTES * 4 // 3 + 4
108
-
109
-
110
- def _entries_from_zip(bundle_b64: str) -> list[tuple[PurePosixPath, bytes]]:
111
- """Decode a base64 zip and return its (safe relpath, bytes) file entries.
112
-
113
- Zip-bomb guard: each member is read through a bounded stream so a lying
114
- uncompressed-size header cannot force unbounded decompression — the running
115
- total is checked against `MAX_BUNDLE_TOTAL_BYTES` as bytes are pulled. A cheap
116
- length check on the still-encoded string runs FIRST, so an oversized payload is
117
- refused before it is buffered into memory as decoded bytes.
118
- """
119
- if len(bundle_b64) > _MAX_BUNDLE_B64_CHARS:
120
- raise_payload_too_large(message=f"bundle_b64 exceeds the {MAX_BUNDLE_TOTAL_BYTES // 1024} KiB compressed-size limit")
121
- try:
122
- raw = base64.b64decode(bundle_b64, validate=True)
123
- except (binascii.Error, ValueError) as decode_error:
124
- log.warning(f"bundle: invalid base64 ({decode_error})")
125
- raise_bad_request(message="bundle_b64 is not valid base64", error_type=ErrorType.INVALID_BASE64)
126
-
127
- try:
128
- archive = zipfile.ZipFile(BytesIO(raw))
129
- except zipfile.BadZipFile as zip_error:
130
- log.warning(f"bundle: corrupt zip ({zip_error})")
131
- raise_validation_error(message="bundle_b64 is not a valid zip archive", error_type=ErrorType.INVALID_BUNDLE)
132
-
133
- entries: list[tuple[PurePosixPath, bytes]] = []
134
- total_bytes = 0
135
- with archive:
136
- members = [info for info in archive.infolist() if not info.is_dir()]
137
- _guard_count(len(members))
138
- budget = MAX_BUNDLE_TOTAL_BYTES
139
- for info in members:
140
- relpath = _safe_relpath(info.filename)
141
- if not relpath.parts:
142
- continue
143
- # Bounded read: pull at most (remaining budget + 1) bytes so a zip bomb whose header
144
- # under-reports its size still cannot decompress past the ceiling.
145
- with archive.open(info) as member:
146
- data = member.read(budget + 1)
147
- total_bytes += len(data)
148
- _guard_running_total(total_bytes)
149
- budget = MAX_BUNDLE_TOTAL_BYTES - total_bytes
150
- entries.append((relpath, data))
151
- return entries
152
-
153
-
154
- def _entries_from_files(files: dict[str, str]) -> list[tuple[PurePosixPath, bytes]]:
155
- """Validate a {relpath: text} map and return its (safe relpath, bytes) entries."""
156
- _guard_count(len(files))
157
- entries: list[tuple[PurePosixPath, bytes]] = []
158
- total_bytes = 0
159
- for name, content in files.items():
160
- relpath = _safe_relpath(name)
161
- if not relpath.parts:
162
- msg = f"Bundle entry {name!r} has no filename"
163
- raise_validation_error(message=msg, error_type=ErrorType.INVALID_BUNDLE)
164
- data = content.encode("utf-8")
165
- total_bytes += len(data)
166
- _guard_running_total(total_bytes)
167
- entries.append((relpath, data))
168
- return entries
169
-
170
-
171
- class ParsedBundle(NamedTuple):
172
- """A decoded-and-validated bundle held in memory, NOT yet written to disk.
173
-
174
- Splitting parse from materialize lets the caller apply the sandbox-hosted gate
175
- (which keys on `has_python_sources`) BEFORE any disk write — so a bundle destined
176
- for a 403 never touches the filesystem. Entries are `(safe relpath, bytes)`.
177
- """
178
-
179
- entries: tuple[tuple[PurePosixPath, bytes], ...]
180
-
181
- @property
182
- def has_python_sources(self) -> bool:
183
- """True when the bundle ships any `.py` — the trigger for the hosted-mode gate."""
184
- return any(str(relpath).endswith(".py") for relpath, _ in self.entries)
185
-
186
-
187
- def parse_bundle(*, bundle_b64: str | None, files: dict[str, str] | None) -> ParsedBundle:
188
- """Decode + guard a bundle into an in-memory `ParsedBundle` (no disk writes).
189
-
190
- Exactly one of `bundle_b64` / `files` must be supplied; supplying both is a
191
- caller mistake (they are the same content in two forms) and is refused. All
192
- ingest guards (base64, size, count, path-safety, zip-bomb) run here, so the
193
- caller can inspect `has_python_sources` and reject BEFORE materializing to disk.
194
- """
195
- if bundle_b64 is not None and files is not None:
196
- msg = "Provide either bundle_b64 or files, not both"
197
- raise_validation_error(message=msg, error_type=ErrorType.INVALID_BUNDLE)
198
- if bundle_b64 is not None:
199
- entries = _entries_from_zip(bundle_b64)
200
- elif files is not None:
201
- entries = _entries_from_files(files)
202
- else:
203
- msg = "No bundle supplied (bundle_b64 and files are both absent)"
204
- raise_validation_error(message=msg, error_type=ErrorType.INVALID_BUNDLE)
205
- return ParsedBundle(entries=tuple(entries))
206
-
207
-
208
- @contextmanager
209
- def materialize_parsed(parsed: ParsedBundle) -> Generator[MaterializedBundle, None, None]:
210
- """Write an already-parsed bundle into a fresh temp directory, cleaned up on exit.
211
-
212
- The yielded `MaterializedBundle.directory` is safe to pass as a `library_dirs`
213
- entry; it is removed when the context exits, on both the happy and error path.
214
- """
215
- directory = Path(tempfile.mkdtemp(prefix="pipelex-bundle-"))
216
- try:
217
- root = directory.resolve()
218
- relpaths: list[str] = []
219
- for relpath, data in parsed.entries:
220
- target = (directory / relpath).resolve()
221
- # Defense-in-depth: even after per-part validation, confirm the resolved target stays
222
- # under the temp root before writing (guards against symlink/edge normalization surprises).
223
- if root != target and root not in target.parents:
224
- msg = f"Bundle entry {str(relpath)!r} resolves outside the bundle root"
225
- raise_validation_error(message=msg, error_type=ErrorType.INVALID_BUNDLE)
226
- target.parent.mkdir(parents=True, exist_ok=True)
227
- target.write_bytes(data)
228
- relpaths.append(relpath.as_posix())
229
- yield MaterializedBundle(directory=directory, relpaths=tuple(relpaths))
230
- finally:
231
- shutil.rmtree(directory, ignore_errors=True)
232
-
233
-
234
- @contextmanager
235
- def materialized_bundle(*, bundle_b64: str | None, files: dict[str, str] | None) -> Generator[MaterializedBundle, None, None]:
236
- """Parse AND materialize a bundle in one step (convenience for callers that don't gate).
237
-
238
- Equivalent to `parse_bundle(...)` followed by `materialize_parsed(...)`; a caller
239
- that must apply the sandbox-hosted gate before disk writes should use the two
240
- steps directly instead.
241
- """
242
- with materialize_parsed(parse_bundle(bundle_b64=bundle_b64, files=files)) as bundle:
243
- yield bundle
File without changes
File without changes
File without changes