From 5320d519a50182673444727d432eb6c172347348 Mon Sep 17 00:00:00 2001 From: Mahmoud Mabrouk Date: Mon, 20 Jul 2026 14:34:50 +0200 Subject: [PATCH 1/9] fix(api): resolve archive mounts eagerly so bad requests 404 instead of a broken 200 The download-all archive resolved each mount lazily inside the streaming generator, after the 200 headers were sent, so MountNotFound / MountStorageUnavailable never reached @handle_mount_exceptions and a bad mount id yielded a corrupt zip. Split iter_archive_members into build_archive_work_list (resolve + list, awaited before the StreamingResponse is built) and iter_archive_members (prefetch/yield only), so those errors surface as real 404/503 through the decorators. Claude-Session: https://claude.ai/code/session_01PTkjdoAPbhSEFR3ubpgzBU --- api/oss/src/apis/fastapi/mounts/utils.py | 7 ++- api/oss/src/core/mounts/service.py | 26 +++++++--- .../tests/pytest/unit/test_mounts_file_ops.py | 49 +++++++++++++++++++ 3 files changed, 72 insertions(+), 10 deletions(-) diff --git a/api/oss/src/apis/fastapi/mounts/utils.py b/api/oss/src/apis/fastapi/mounts/utils.py index 816955008a..a76d38bf57 100644 --- a/api/oss/src/apis/fastapi/mounts/utils.py +++ b/api/oss/src/apis/fastapi/mounts/utils.py @@ -98,6 +98,10 @@ async def stream_mounts_archive( service prefetches file bodies with bounded concurrency. ``ZIP_AUTO`` picks zip32/zip64 per file by size, so large drives and >4 GB archives are handled. """ + work = await mounts_service.build_archive_work_list( + project_id=project_id, + mounts=mounts, + ) async def members(): async for ( @@ -106,8 +110,7 @@ async def members(): mtime, body, ) in mounts_service.iter_archive_members( - project_id=project_id, - mounts=mounts, + work=work, ): # `mtime` is the store's LastModified as epoch MILLISECONDS (see StoreObject.mtime); # `datetime.fromtimestamp` wants SECONDS — passing ms overflows to a year out of range diff --git a/api/oss/src/core/mounts/service.py b/api/oss/src/core/mounts/service.py index 40414fdb70..91ad5e70c4 100644 --- a/api/oss/src/core/mounts/service.py +++ b/api/oss/src/core/mounts/service.py @@ -1059,23 +1059,22 @@ async def read_file_bytes( key = self._storage_key(project_id=project_id, mount=mount, path=path) return await self.mounts_store.get_object(bucket=self._bucket(), key=key) - async def iter_archive_members( + async def build_archive_work_list( self, *, project_id: UUID, mounts: List[Tuple[UUID, str, str]], - concurrency: int = _ARCHIVE_READ_CONCURRENCY, - ) -> AsyncIterator[Tuple[str, int, Optional[int], bytes]]: - """Yield ``(zip_path, size, mtime, raw_bytes)`` for the files in the given mounts, in order — - the basis for a STREAMING archive. Each mount is a ``(mount_id, zip_prefix, source_path)``: + ) -> List[Tuple[str, str, int, Optional[int]]]: + """Build the ordered archive work list for the given mounts. + + Each mount is a ``(mount_id, zip_prefix, source_path)``: ``source_path`` scopes it to a FOLDER within the mount ("" = the whole mount, for "download all"); ``zip_prefix`` places its files under ``prefix/`` in the zip (e.g. "agent-files" for - the folded agent mount). Folder markers are skipped. Reads up to ``concurrency`` files AHEAD - (bounded ordered prefetch) — never buffering the zip whole nor hammering the store. + the folded agent mount). Folder markers are skipped. Each work item is a + ``(zip_path, storage_key, size, mtime)`` tuple. """ bucket = self._bucket() - # Full ordered work list across mounts: (zip_path, storage_key, size, mtime). work: List[Tuple[str, str, int, Optional[int]]] = [] for mount_id, prefix, source_path in mounts: mount = await self._resolve_mount(project_id=project_id, mount_id=mount_id) @@ -1101,6 +1100,17 @@ async def iter_archive_members( zip_path = f"{pfx}/{rel}" if pfx else rel work.append((zip_path, obj.key, obj.size or 0, obj.mtime)) + return work + + async def iter_archive_members( + self, + *, + work: List[Tuple[str, str, int, Optional[int]]], + concurrency: int = _ARCHIVE_READ_CONCURRENCY, + ) -> AsyncIterator[Tuple[str, int, Optional[int], bytes]]: + """Yield ``(zip_path, size, mtime, raw_bytes)`` with bounded ordered prefetch.""" + bucket = self._bucket() + # Ordered bounded-concurrency prefetch: keep ~`concurrency` reads in flight, yield in order. inflight: deque = deque() cursor = 0 diff --git a/api/oss/tests/pytest/unit/test_mounts_file_ops.py b/api/oss/tests/pytest/unit/test_mounts_file_ops.py index d49adf37eb..c1543ab26a 100644 --- a/api/oss/tests/pytest/unit/test_mounts_file_ops.py +++ b/api/oss/tests/pytest/unit/test_mounts_file_ops.py @@ -22,6 +22,7 @@ from oss.src.core.store.dtos import StoreObject from oss.src.core.mounts.types import ( MountFileNotFound, + MountNotFound, MountPathInvalid, ) @@ -137,6 +138,11 @@ async def fetch_mount(self, *, project_id, mount_id): return self._mount +class _MissingMountDAO: + async def fetch_mount(self, *, project_id, mount_id): + return None + + def _make_mount() -> Mount: return Mount( id=uuid4(), @@ -154,6 +160,49 @@ def _make_service(mount: Mount) -> Tuple[MountsService, UUID, UUID]: return service, mount.project_id, mount.id +# --------------------------------------------------------------------------- +# Archive work list +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +class TestArchiveWorkList: + async def test_missing_mount_raises_during_work_list_build(self): + pid = uuid4() + service = MountsService( + mounts_dao=_MissingMountDAO(), + mounts_store=FakeMountStorage(), + bucket=_BUCKET, + ) + + with pytest.raises(MountNotFound): + await service.build_archive_work_list( + project_id=pid, + mounts=[(uuid4(), "", "")], + ) + + async def test_zip_paths_include_mount_prefix(self): + mount = _make_mount() + service, pid, mid = _make_service(mount) + for path in ["one.txt", "nested/two.txt"]: + await service.write_file( + project_id=pid, + mount_id=mid, + path=path, + content=b"x", + ) + + work = await service.build_archive_work_list( + project_id=pid, + mounts=[(mid, "prefix", "")], + ) + + assert {zip_path for zip_path, _key, _size, _mtime in work} == { + "prefix/one.txt", + "prefix/nested/two.txt", + } + + # --------------------------------------------------------------------------- # Roundtrip # --------------------------------------------------------------------------- From 6616af95b43f41ae047c245258f61ad73a628db9 Mon Sep 17 00:00:00 2001 From: Mahmoud Mabrouk Date: Mon, 20 Jul 2026 14:38:14 +0200 Subject: [PATCH 2/9] fix(api): cap the plain count-only mount listing at _COUNT_CAP The raw (non-git-aware) count-only path ran a full uncapped list_objects_v2 and hardcoded truncated=False, so a huge tree was enumerated whole. Page it bounded at _COUNT_CAP and report total_capped=True when more remain, matching the git-aware branch's bounded-count contract. Re-adds the generic ObjectStore.list_objects_page pagination primitive (removed alongside the flat Files view); this bounded count is now its sole consumer. Claude-Session: https://claude.ai/code/session_01PTkjdoAPbhSEFR3ubpgzBU --- api/oss/src/core/mounts/service.py | 27 +++++++ api/oss/src/core/store/storage.py | 30 +++++++ .../tests/pytest/unit/test_mounts_file_ops.py | 80 +++++++++++++++++++ 3 files changed, 137 insertions(+) diff --git a/api/oss/src/core/mounts/service.py b/api/oss/src/core/mounts/service.py index 91ad5e70c4..4f0c714d3a 100644 --- a/api/oss/src/core/mounts/service.py +++ b/api/oss/src/core/mounts/service.py @@ -946,6 +946,33 @@ async def _count_children(sub_rel: str) -> Tuple[str, int]: store_files, specs, truncated = await self._list_pruned_files( base_prefix=list_prefix, mount_base=mount_base, cap=cap ) + elif cap is not None: + # RAW count-only: page until MORE than `cap` real files are known to exist (the UI + # then shows "N+") or the tree is exhausted, so a huge tree can't run away (matches + # the git-aware branch's bounded-count contract). `has_more` counts OBJECTS, not + # files, so truncation is decided on the file count alone — folder markers never + # inflate `total` into a false "N+" (they are assumed sparse; a marker-only tree is + # the one case still paged to exhaustion). + store_files = [] + specs: List[Tuple[str, "pathspec.PathSpec"]] = [] + truncated = False + start_after: Optional[str] = None + while len(store_files) <= cap: + objs, has_more = await self.mounts_store.list_objects_page( + bucket=self._bucket(), + prefix=list_prefix, + start_after=start_after, + max_keys=max(cap, 200), + ) + if not objs: + break + start_after = objs[-1].key + store_files.extend(o for o in objs if not o.key.endswith("/")) + if not has_more: + break + if len(store_files) > cap: + truncated = True + store_files = store_files[:cap] else: # RAW: every object under the prefix, no pruning (matches the plain-endpoint contract). objects = await self.mounts_store.list_objects_v2( diff --git a/api/oss/src/core/store/storage.py b/api/oss/src/core/store/storage.py index d528085be1..b03f05d0dc 100644 --- a/api/oss/src/core/store/storage.py +++ b/api/oss/src/core/store/storage.py @@ -335,6 +335,36 @@ async def list_objects_v2( ) return results + async def list_objects_page( + self, + *, + bucket: str, + prefix: str, + start_after: Optional[str] = None, + max_keys: int = 500, + ) -> "Tuple[List[StoreObject], bool]": + """One PAGE of objects under `prefix`, in the store's lexicographic (= path) order, resuming + strictly AFTER `start_after`. Returns `(objects, has_more)`. Streams lazily and stops after + `max_keys` — so it NEVER enumerates a huge subtree; this is the basis for bounded counting + (the caller carries the last key forward as `start_after`). One extra element is pulled to + detect `has_more`, then dropped.""" + client = self._client() + results: List[StoreObject] = [] + async for obj in client.list_objects( + bucket, prefix=prefix, recursive=True, start_after=start_after or None + ): + if obj is None: + continue + # We already have a full page — the presence of one more object means more remain. + if len(results) >= max_keys: + return results, True + last_modified = getattr(obj, "last_modified", None) + mtime = int(last_modified.timestamp() * 1000) if last_modified else None + results.append( + StoreObject(key=obj.object_name, size=obj.size or 0, mtime=mtime) + ) + return results, False + async def list_objects_shallow( self, *, diff --git a/api/oss/tests/pytest/unit/test_mounts_file_ops.py b/api/oss/tests/pytest/unit/test_mounts_file_ops.py index c1543ab26a..2a1d3c0032 100644 --- a/api/oss/tests/pytest/unit/test_mounts_file_ops.py +++ b/api/oss/tests/pytest/unit/test_mounts_file_ops.py @@ -17,6 +17,7 @@ import pytest +from oss.src.core.mounts import service as mounts_service_module from oss.src.core.mounts.dtos import Mount from oss.src.core.mounts.service import MountsService, validate_file_path from oss.src.core.store.dtos import StoreObject @@ -81,6 +82,21 @@ async def list_objects_v2(self, *, bucket: str, prefix: str) -> List[StoreObject if k.startswith(prefix) ] + async def list_objects_page( + self, *, bucket: str, prefix: str, start_after=None, max_keys: int = 500 + ) -> Tuple[List[StoreObject], bool]: + # One bounded page in the store's UTF-8 byte order, resuming strictly after `start_after`. + b = self._store.get(bucket, {}) + keys = sorted( + (k for k in b if k.startswith(prefix)), key=lambda k: k.encode("utf-8") + ) + if start_after is not None: + sa = start_after.encode("utf-8") + keys = [k for k in keys if k.encode("utf-8") > sa] + page = keys[:max_keys] + has_more = len(keys) > max_keys + return [StoreObject(key=k, size=len(b[k])) for k in page], has_more + async def list_objects_shallow(self, *, bucket: str, prefix: str): # One level under `prefix` (delimiter "/"): immediate files + immediate subdir prefixes. # Mirrors the real store: a trailing-slash key is a folder marker / common-prefix (a subdir), @@ -323,6 +339,70 @@ async def test_count_only_returns_total_no_files(self): assert listing.total_capped is False assert listing.files == [] + async def test_raw_count_only_caps_total(self, monkeypatch): + monkeypatch.setattr(mounts_service_module, "_COUNT_CAP", 3) + mount = _make_mount() + service, pid, mid = _make_service(mount) + for path in ["a.txt", "b.txt", "c.txt", "d.txt", "e.txt"]: + await service.write_file( + project_id=pid, mount_id=mid, path=path, content=b"x" + ) + + listing = await service.list_files( + project_id=pid, mount_id=mid, limit=0, git_aware=False + ) + + assert listing.total == 3 + assert listing.total_capped is True + assert listing.files == [] + + async def test_raw_count_only_reports_uncapped_total_below_cap(self, monkeypatch): + monkeypatch.setattr(mounts_service_module, "_COUNT_CAP", 3) + mount = _make_mount() + service, pid, mid = _make_service(mount) + for path in ["a.txt", "b.txt"]: + await service.write_file( + project_id=pid, mount_id=mid, path=path, content=b"x" + ) + + listing = await service.list_files( + project_id=pid, mount_id=mid, limit=0, git_aware=False + ) + + assert listing.total == 2 + assert listing.total_capped is False + assert listing.files == [] + + async def test_raw_count_only_ignores_trailing_folder_markers(self, monkeypatch): + # Exactly `_COUNT_CAP` real files followed only by folder markers is an EXACT count, not a + # floor: the object-level `has_more` (markers still to page) must not report a false "N+". + monkeypatch.setattr(mounts_service_module, "_COUNT_CAP", 3) + mount = _make_mount() + storage = FakeMountStorage() + service = MountsService( + mounts_dao=_StubDAO(mount), + mounts_store=storage, + bucket=_BUCKET, + ) + pid, mid = mount.project_id, mount.id + for path in ["a.txt", "b.txt", "c.txt"]: + await service.write_file( + project_id=pid, mount_id=mid, path=path, content=b"x" + ) + # More markers than one store page (max(cap, 200)) so the first page reports has_more=True. + mount_base = service._storage_key(project_id=pid, mount=mount) + bucket_store = storage._store.setdefault(_BUCKET, {}) + for i in range(250): + bucket_store[f"{mount_base}zzz{i:04}/"] = b"" + + listing = await service.list_files( + project_id=pid, mount_id=mid, limit=0, git_aware=False + ) + + assert listing.total == 3 + assert listing.total_capped is False + assert listing.files == [] + async def test_shallow_depth_lists_top_level_only(self): # depth=1 → ONE delimiter level: top-level files + folders, no descent into subtrees. The # nested `sub/c.txt` surfaces its parent `sub` as a folder (never the deep file), and a From d7d029ce9cdb70752a5e0778c4932fd947a92034 Mon Sep 17 00:00:00 2001 From: Mahmoud Mabrouk Date: Mon, 20 Jul 2026 14:41:16 +0200 Subject: [PATCH 3/9] test(api): pin _rollup_recent_entries batch-collapse behavior Adds pure-function tests for the recent-files rollup: clone-then-edit collapses to the top directory, an old-plus-fresh directory does not collapse, an untimed leaf blocks collapse, a single-batch history produces no rollup, and shallow-to-deep resolution picks repo/ over repo/web/. Pins current behavior; no algorithm change. Claude-Session: https://claude.ai/code/session_01PTkjdoAPbhSEFR3ubpgzBU --- .../tests/pytest/unit/test_mounts_file_ops.py | 75 ++++++++++++++++++- 1 file changed, 73 insertions(+), 2 deletions(-) diff --git a/api/oss/tests/pytest/unit/test_mounts_file_ops.py b/api/oss/tests/pytest/unit/test_mounts_file_ops.py index 2a1d3c0032..d674e50aad 100644 --- a/api/oss/tests/pytest/unit/test_mounts_file_ops.py +++ b/api/oss/tests/pytest/unit/test_mounts_file_ops.py @@ -18,8 +18,12 @@ import pytest from oss.src.core.mounts import service as mounts_service_module -from oss.src.core.mounts.dtos import Mount -from oss.src.core.mounts.service import MountsService, validate_file_path +from oss.src.core.mounts.dtos import Mount, MountFile +from oss.src.core.mounts.service import ( + MountsService, + _rollup_recent_entries, + validate_file_path, +) from oss.src.core.store.dtos import StoreObject from oss.src.core.mounts.types import ( MountFileNotFound, @@ -64,6 +68,73 @@ def test_rejects_special_chars(self): validate_file_path("file;rm -rf") +class TestRollupRecentEntries: + def test_clone_then_edit_collapses_to_top_directory(self): + files = [ + MountFile(path="repo/a.txt", mtime=1000, size=1), + MountFile(path="repo/web/b.txt", mtime=1001, size=1), + MountFile(path="repo/web/c.txt", mtime=1002, size=1), + MountFile(path="note.txt", mtime=5000, size=1), + ] + + by_path = {entry.path: entry for entry in _rollup_recent_entries(files, None)} + + assert by_path["repo"].is_folder is True + assert "note.txt" in by_path + assert not by_path["note.txt"].is_folder + assert not any(path.startswith("repo/") for path in by_path) + + def test_old_plus_fresh_directory_does_not_collapse(self): + files = [ + MountFile(path="dir/old.txt", mtime=100, size=1), + MountFile(path="dir/new.txt", mtime=5000, size=1), + MountFile(path="recent.txt", mtime=5001, size=1), + ] + + by_path = {entry.path: entry for entry in _rollup_recent_entries(files, None)} + + assert "dir" not in by_path + assert "dir/old.txt" in by_path + assert "dir/new.txt" in by_path + + def test_untimed_leaf_blocks_collapse(self): + files = [ + MountFile(path="batch/a.txt", mtime=1000, size=1), + MountFile(path="batch/b.txt", mtime=None, size=1), + MountFile(path="later.txt", mtime=5000, size=1), + ] + + by_path = {entry.path: entry for entry in _rollup_recent_entries(files, None)} + + assert "batch" not in by_path + assert "batch/a.txt" in by_path + assert "batch/b.txt" in by_path + + def test_single_batch_history_produces_no_rollup(self): + files = [ + MountFile(path="repo/a.txt", mtime=1000, size=1), + MountFile(path="repo/b.txt", mtime=1001, size=1), + ] + + result = _rollup_recent_entries(files, None) + + assert all(not entry.is_folder for entry in result) + assert {entry.path for entry in result} == {"repo/a.txt", "repo/b.txt"} + + def test_shallow_to_deep_resolution_picks_repo_over_repo_web(self): + files = [ + MountFile(path="repo/a.txt", mtime=1000, size=1), + MountFile(path="repo/web/b.txt", mtime=1001, size=1), + MountFile(path="repo/web/c.txt", mtime=1002, size=1), + MountFile(path="outside.txt", mtime=9000, size=1), + ] + + result = _rollup_recent_entries(files, None) + folder_paths = {entry.path for entry in result if entry.is_folder} + + assert folder_paths == {"repo"} + + # --------------------------------------------------------------------------- # In-memory fake storage (same interface as ObjectStore) # --------------------------------------------------------------------------- From 78fc32784cacea80bdf4ea33d804c2fc85e04aa7 Mon Sep 17 00:00:00 2001 From: Mahmoud Mabrouk Date: Mon, 20 Jul 2026 14:50:09 +0200 Subject: [PATCH 4/9] fix(api): relax mount path validation and harden archive inputs Switch validate_file_path from a character allowlist to a denylist so real folder names (route groups, npm scopes, `c++`, `#`, `~`, non-ASCII) that the lazy-browse flow round-trips no longer 422 on every expand. Reject only unsafe input: absolute paths, empty/`.`/`..` segments, NUL and control characters. Harden the archive path in the same pass (opposite direction): validate ArchiveMount prefix/path, sanitize `..` out of zip entry names to prevent zip-slip, and make the Content-Disposition fallback ASCII-only so a non-latin-1 filename stops 500ing on Starlette's latin-1 header encoding. Claude-Session: https://claude.ai/code/session_01PTkjdoAPbhSEFR3ubpgzBU --- api/oss/src/apis/fastapi/mounts/utils.py | 14 ++- api/oss/src/core/mounts/service.py | 58 +++++++++--- .../tests/pytest/unit/test_mounts_file_ops.py | 91 ++++++++++++++++++- 3 files changed, 143 insertions(+), 20 deletions(-) diff --git a/api/oss/src/apis/fastapi/mounts/utils.py b/api/oss/src/apis/fastapi/mounts/utils.py index a76d38bf57..2f78031672 100644 --- a/api/oss/src/apis/fastapi/mounts/utils.py +++ b/api/oss/src/apis/fastapi/mounts/utils.py @@ -22,13 +22,19 @@ def _content_disposition_attachment(filename: str) -> str: `filename` is client-supplied (a download path's basename or the archive name), so it must never be interpolated raw: a `"` or control char would break out of the quoted parameter and inject - further header directives. The quoted `filename` is stripped to a printable, quote-free ASCII-ish - fallback; `filename*` carries the exact (percent-encoded) value for clients that honour it. + further header directives. The quoted `filename` is stripped to an ASCII, printable, quote-free + fallback (the header is latin-1 encoded); `filename*` carries the exact (percent-encoded) value for + clients that honour it. """ safe = ( - "".join(c for c in filename if c.isprintable() and c not in '"\\') or "download" + "".join( + c for c in filename if c.isascii() and c.isprintable() and c not in '"\\' + ) + or "download" + ) + return ( + f"attachment; filename=\"{safe}\"; filename*=UTF-8''{quote(filename, safe='')}" ) - return f"attachment; filename=\"{safe}\"; filename*=UTF-8''{quote(filename)}" async def upload_mount_file( diff --git a/api/oss/src/core/mounts/service.py b/api/oss/src/core/mounts/service.py index 4f0c714d3a..86709e7ea5 100644 --- a/api/oss/src/core/mounts/service.py +++ b/api/oss/src/core/mounts/service.py @@ -2,7 +2,7 @@ from bisect import bisect_left, bisect_right from collections import deque from posixpath import basename -from re import fullmatch, sub +from re import sub from typing import AsyncIterator, TYPE_CHECKING, List, Optional, Tuple from uuid import UUID, uuid5, NAMESPACE_DNS @@ -38,9 +38,9 @@ MountStorageUnavailable, ) from oss.src.core.shared.dtos import Reference, Windowing +from oss.src.utils.logging import get_module_logger -# Folder/file path segments: word chars, dots, spaces, hyphens — no path traversal. -_SEGMENT_RE = r"[\w. -]+" +log = get_module_logger(__name__) # Reserved slug prefix for service-minted (session) slugs; a caller may not author one. _RESERVED_SLUG_PREFIX = "__ag__" @@ -107,23 +107,41 @@ def reject_reserved_slug(slug: str) -> None: def validate_file_path(path: str) -> None: - """Per-segment guard on a caller-supplied file/folder path. + """Guard a caller-supplied file/folder path against escaping the mount or corrupting a store key. - Rejects absolute paths, `..` traversal, and any segment that could escape - the mount prefix. Dots are allowed within a segment (filenames) but a bare - `..` segment is not. + Denylist, not allowlist: reject absolute paths, empty / `.` / `..` segments, and NUL or control + characters. Every other character real filenames contain — parentheses, brackets, `@ + , # ~ '`, + non-ASCII — is accepted, because the lazy-browse flow round-trips these paths on every expansion. """ if path.startswith("/"): raise MountPathInvalid("File path must not be absolute.") if not path.strip("/"): raise MountPathInvalid("File path must not be empty.") - for segment in path.split("/"): - if not segment: - continue - if segment == ".." or not fullmatch(_SEGMENT_RE, segment): + if any(ord(c) < 0x20 or c == "\x7f" for c in path): + raise MountPathInvalid("File path must not contain control characters.") + for segment in path.strip("/").split("/"): + if segment in ("", ".", ".."): raise MountPathInvalid() +def _zip_segments(path: str) -> List[str]: + """Split a zip entry path on BOTH separators — a backslash is a separator to Windows extractors, + so `..\\x` traverses just like `../x`.""" + return path.replace("\\", "/").split("/") + + +def _has_unsafe_zip_segment(segments: List[str]) -> bool: + """True if any segment is empty / `.` / `..` — i.e. the path could traverse out of the zip root + (zip-slip for whoever extracts).""" + return any(s in ("", ".", "..") for s in segments) + + +def _safe_zip_segments(path: str) -> List[str]: + """Segments safe to place in a zip entry name: drop empty / `.` / `..` (both separators) so a + prefix can't mint a `../x` or `..\\x` entry.""" + return [s for s in _zip_segments(path) if s not in ("", ".", "..")] + + def _is_internal_mount_path(path: str) -> bool: """Runner-owned runtime artifacts written into the durable cwd — hidden from flat file listings. Mirrors the web `isInternalDrivePath`: the whole `agents/` namespace plus `.agenta-*` markers.""" @@ -1104,9 +1122,13 @@ async def build_archive_work_list( work: List[Tuple[str, str, int, Optional[int]]] = [] for mount_id, prefix, source_path in mounts: + if prefix: + validate_file_path(prefix) + if source_path: + validate_file_path(source_path) mount = await self._resolve_mount(project_id=project_id, mount_id=mount_id) mount_base = self._storage_key(project_id=project_id, mount=mount) - pfx = prefix.strip("/") + pfx_segments = _safe_zip_segments(prefix) src = source_path.strip("/") # Scope the listing to a folder when `source_path` is set (folder download); the # rel path still keeps the folder, so the zip has "/…" entries. @@ -1122,9 +1144,17 @@ async def build_archive_work_list( if obj.key.startswith(mount_base) else obj.key ) - if not rel: + rel_segments = _zip_segments(rel) + # Store keys come from signed-credential writers, so `rel` can carry `..` or a + # backslash. Don't REWRITE such a key — `a/../report.txt` would collapse onto a real + # `a/report.txt` and overwrite it on extraction — skip the member instead. + if _has_unsafe_zip_segment(rel_segments): + log.warning( + "mounts.archive: skipping member with unsafe store key", + key=obj.key, + ) continue - zip_path = f"{pfx}/{rel}" if pfx else rel + zip_path = "/".join([*pfx_segments, *rel_segments]) work.append((zip_path, obj.key, obj.size or 0, obj.mtime)) return work diff --git a/api/oss/tests/pytest/unit/test_mounts_file_ops.py b/api/oss/tests/pytest/unit/test_mounts_file_ops.py index d674e50aad..7566abb609 100644 --- a/api/oss/tests/pytest/unit/test_mounts_file_ops.py +++ b/api/oss/tests/pytest/unit/test_mounts_file_ops.py @@ -17,6 +17,7 @@ import pytest +from oss.src.apis.fastapi.mounts.utils import _content_disposition_attachment from oss.src.core.mounts import service as mounts_service_module from oss.src.core.mounts.dtos import Mount, MountFile from oss.src.core.mounts.service import ( @@ -63,9 +64,59 @@ def test_rejects_empty(self): with pytest.raises(MountPathInvalid): validate_file_path("") - def test_rejects_special_chars(self): + def test_accepts_parentheses_and_brackets(self): + validate_file_path("app/(auth)/[slug]/page.tsx") + + def test_accepts_at_sign(self): + validate_file_path("@scope/pkg/index.js") + + def test_accepts_plus_signs(self): + validate_file_path("c++.md") + + def test_accepts_comma(self): + validate_file_path("a,b.txt") + + def test_accepts_hash(self): + validate_file_path("notes#1.txt") + + def test_accepts_tilde(self): + validate_file_path("~backup") + + def test_accepts_astral_plane_name(self): + validate_file_path("\U00020000dir/file.txt") + + def test_rejects_empty_interior_segment(self): + with pytest.raises(MountPathInvalid): + validate_file_path("a//b") + + def test_rejects_dot_segment(self): + with pytest.raises(MountPathInvalid): + validate_file_path("a/./b") + + def test_rejects_embedded_nul(self): + with pytest.raises(MountPathInvalid): + validate_file_path("a/b\x00c") + + def test_rejects_control_character(self): with pytest.raises(MountPathInvalid): - validate_file_path("file;rm -rf") + validate_file_path("a/b\x01c") + + +class TestContentDispositionHeader: + @pytest.mark.parametrize( + "filename", + [ + "中文报告.zip", + "photo\U0001f600.zip", + 'a"; DROP TABLE.zip', + "a\nb.zip", + ], + ) + def test_header_is_latin_1_safe_with_utf_8_filename(self, filename): + header = _content_disposition_attachment(filename) + + header.encode("latin-1") + assert "filename*=UTF-8''" in header class TestRollupRecentEntries: @@ -290,6 +341,42 @@ async def test_zip_paths_include_mount_prefix(self): } +@pytest.mark.asyncio +class TestArchiveZipSlip: + async def _work_for_keys(self, keys): + mount = _make_mount() + storage = FakeMountStorage() + service = MountsService( + mounts_dao=_StubDAO(mount), + mounts_store=storage, + bucket=_BUCKET, + ) + mount_base = service._storage_key(project_id=mount.project_id, mount=mount) + bucket_store = storage._store.setdefault(_BUCKET, {}) + for key in keys: + bucket_store[f"{mount_base}{key}"] = b"x" + return await service.build_archive_work_list( + project_id=mount.project_id, + mounts=[(mount.id, "", "")], + ) + + async def test_traversal_keys_are_skipped_not_rewritten(self): + # `../evil.txt` and `..\evil.txt` (backslash is a separator to Windows extractors) must not + # produce an entry that escapes the archive root; the safe sibling still ships. + work = await self._work_for_keys(["good.txt", "../evil.txt", "..\\evil.txt"]) + + zip_paths = {zip_path for zip_path, *_rest in work} + assert zip_paths == {"good.txt"} + + async def test_traversal_key_does_not_alias_a_real_entry(self): + # `a/../report.txt` must NOT be rewritten to `a/report.txt` — that would overwrite the real + # `a/report.txt` on extraction. Skipping it leaves the genuine file intact and un-duplicated. + work = await self._work_for_keys(["a/report.txt", "a/../report.txt"]) + + zip_paths = [zip_path for zip_path, *_rest in work] + assert zip_paths == ["a/report.txt"] + + # --------------------------------------------------------------------------- # Roundtrip # --------------------------------------------------------------------------- From daf3f6a18bf2e41bfa9206e45de3b03461b3f361 Mon Sep 17 00:00:00 2001 From: Mahmoud Mabrouk Date: Mon, 20 Jul 2026 15:01:23 +0200 Subject: [PATCH 5/9] fix(api): rename download-all route to POST /files/export "archive" is this router's soft-delete lifecycle verb (archive_mount two registrations below), and the collision is what forced the route-ordering hack. Rename the download-all zip route to the non-lifecycle /files/export and update the operation_id, handler, and all three frontend touchpoints (driveMedia streaming + buffered paths, and the hand-added client method, which stays hand-written until the Fern regen ticket). Claude-Session: https://claude.ai/code/session_01PTkjdoAPbhSEFR3ubpgzBU --- api/oss/src/apis/fastapi/mounts/router.py | 12 ++++++------ web/oss/src/components/Drives/driveMedia.ts | 4 ++-- .../generated/api/resources/mounts/client/Client.ts | 12 ++++++------ 3 files changed, 14 insertions(+), 14 deletions(-) diff --git a/api/oss/src/apis/fastapi/mounts/router.py b/api/oss/src/apis/fastapi/mounts/router.py index 54dc29dc52..c906653f8c 100644 --- a/api/oss/src/apis/fastapi/mounts/router.py +++ b/api/oss/src/apis/fastapi/mounts/router.py @@ -191,13 +191,13 @@ def __init__( response_model_exclude_none=True, status_code=status.HTTP_200_OK, ) - # Registered BEFORE "/{mount_id}/archive" so `POST /files/archive` (download-all zip) isn't - # captured as archiving a mount literally named "files". + # Registered before the "/{mount_id}/..." routes so this literal path isn't captured as an + # operation on a mount named "files". self.router.add_api_route( - "/files/archive", - self.archive_mount_files, + "/files/export", + self.export_mount_files, methods=["POST"], - operation_id="archive_mount_files", + operation_id="export_mount_files", response_model=None, status_code=status.HTTP_200_OK, ) @@ -617,7 +617,7 @@ async def download_mount_file( @intercept_exceptions() @handle_mount_exceptions() - async def archive_mount_files( + async def export_mount_files( self, request: Request, *, diff --git a/web/oss/src/components/Drives/driveMedia.ts b/web/oss/src/components/Drives/driveMedia.ts index 709af6b4df..943c3e6899 100644 --- a/web/oss/src/components/Drives/driveMedia.ts +++ b/web/oss/src/components/Drives/driveMedia.ts @@ -229,7 +229,7 @@ export async function downloadMountArchive({ const writable = await handle.createWritable() try { const jwt = await getJWT() - const url = `${getAgentaApiUrl()}/mounts/files/archive?project_id=${encodeURIComponent(projectId)}` + const url = `${getAgentaApiUrl()}/mounts/files/export?project_id=${encodeURIComponent(projectId)}` const response = await fetch(url, { method: "POST", headers: { @@ -257,7 +257,7 @@ export async function downloadMountArchive({ // ─── Buffered fallback (Safari / Firefox / no picker) ───────────────────────────────────────── try { - const response = await axios.post(`${getAgentaApiUrl()}/mounts/files/archive`, payload, { + const response = await axios.post(`${getAgentaApiUrl()}/mounts/files/export`, payload, { params: {project_id: projectId}, responseType: "blob", }) diff --git a/web/packages/agenta-api-client/src/generated/api/resources/mounts/client/Client.ts b/web/packages/agenta-api-client/src/generated/api/resources/mounts/client/Client.ts index 70f1a88cdb..61fb1b2b17 100644 --- a/web/packages/agenta-api-client/src/generated/api/resources/mounts/client/Client.ts +++ b/web/packages/agenta-api-client/src/generated/api/resources/mounts/client/Client.ts @@ -532,16 +532,16 @@ export class MountsClient { * @throws {@link AgentaApi.UnprocessableEntityError} * * @example - * await client.mounts.archiveMountFiles() + * await client.mounts.exportMountFiles() */ - public archiveMountFiles( + public exportMountFiles( request: AgentaApi.MountArchiveRequest = {}, requestOptions?: MountsClient.RequestOptions, ): core.HttpResponsePromise { - return core.HttpResponsePromise.fromPromise(this.__archiveMountFiles(request, requestOptions)); + return core.HttpResponsePromise.fromPromise(this.__exportMountFiles(request, requestOptions)); } - private async __archiveMountFiles( + private async __exportMountFiles( request: AgentaApi.MountArchiveRequest = {}, requestOptions?: MountsClient.RequestOptions, ): Promise> { @@ -556,7 +556,7 @@ export class MountsClient { (await core.Supplier.get(this._options.baseUrl)) ?? (await core.Supplier.get(this._options.environment)) ?? environments.AgentaApiEnvironment.Default, - "mounts/files/archive", + "mounts/files/export", ), method: "POST", headers: _headers, @@ -591,7 +591,7 @@ export class MountsClient { } } - return handleNonStatusCodeError(_response.error, _response.rawResponse, "POST", "/mounts/files/archive"); + return handleNonStatusCodeError(_response.error, _response.rawResponse, "POST", "/mounts/files/export"); } /** From a9d9d49cb966b0047eb5a713c276a132513e1110 Mon Sep 17 00:00:00 2001 From: Mahmoud Mabrouk Date: Mon, 20 Jul 2026 15:01:51 +0200 Subject: [PATCH 6/9] fix(api): constrain mount file-listing depth and order params `depth` advertised ge=1 but only depth==1 is implemented; depth=2 silently fell through to the most expensive full-tree browse branch. Constrain it to le=1 so unsupported depths 422. Type `order` as a Literal so a typo 422s at the boundary instead of returning the flat view unsorted. Claude-Session: https://claude.ai/code/session_01PTkjdoAPbhSEFR3ubpgzBU --- api/oss/src/apis/fastapi/mounts/router.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/api/oss/src/apis/fastapi/mounts/router.py b/api/oss/src/apis/fastapi/mounts/router.py index c906653f8c..28484606fa 100644 --- a/api/oss/src/apis/fastapi/mounts/router.py +++ b/api/oss/src/apis/fastapi/mounts/router.py @@ -1,5 +1,5 @@ from functools import wraps -from typing import Optional +from typing import Literal, Optional from uuid import UUID from fastapi import APIRouter, HTTPException, Query, Request, UploadFile, status @@ -499,9 +499,11 @@ async def get_mount_files( *, path: Optional[str] = Query(default=None), read: Optional[str] = Query(default=None), - order: Optional[str] = Query(default=None), + order: Optional[Literal["recent", "name", "path"]] = Query(default=None), limit: Optional[int] = Query(default=None, ge=0), - depth: Optional[int] = Query(default=None, ge=1), + # Only depth==1 is implemented (the shallow one-level summary); reject other values loudly + # instead of silently falling through to the most expensive full-tree branch. + depth: Optional[int] = Query(default=None, ge=1, le=1), with_counts: bool = Query(default=False), git_aware: bool = Query(default=False), include_gitignored: bool = Query(default=False), From ce29aa03133272a0d77161c3a710b22c6d75a3d6 Mon Sep 17 00:00:00 2001 From: Mahmoud Mabrouk Date: Mon, 20 Jul 2026 17:23:33 +0200 Subject: [PATCH 7/9] refactor(api): name the archive-source tuple Replace the archive path's anonymous `(mount_id, prefix, path)` triples with a MountArchiveSource DTO, per the typed-DTO convention. Thread it through the service, archive utils, router, and unit tests. (The companion MountFilePage DTO from the original change is dropped: the flat paged listing it named was removed with the Files drawer's flat view.) Claude-Session: https://claude.ai/code/session_01PTkjdoAPbhSEFR3ubpgzBU --- api/oss/src/apis/fastapi/mounts/router.py | 10 ++++++++- api/oss/src/apis/fastapi/mounts/utils.py | 11 +++++++--- api/oss/src/core/mounts/dtos.py | 10 +++++++++ api/oss/src/core/mounts/service.py | 21 +++++++++++-------- .../tests/pytest/unit/test_mounts_file_ops.py | 8 +++---- 5 files changed, 43 insertions(+), 17 deletions(-) diff --git a/api/oss/src/apis/fastapi/mounts/router.py b/api/oss/src/apis/fastapi/mounts/router.py index 28484606fa..7307b16a3f 100644 --- a/api/oss/src/apis/fastapi/mounts/router.py +++ b/api/oss/src/apis/fastapi/mounts/router.py @@ -10,6 +10,7 @@ from oss.src.core.access.permissions.service import check_action_access from oss.src.apis.fastapi.shared.exceptions import FORBIDDEN_EXCEPTION +from oss.src.core.mounts.dtos import MountArchiveSource from oss.src.core.mounts.service import MountsService from oss.src.core.mounts.types import ( MountArtifactIdInvalid, @@ -630,7 +631,14 @@ async def export_mount_files( return await stream_mounts_archive( mounts_service=self.mounts_service, project_id=UUID(request.state.project_id), - mounts=[(m.mount_id, m.prefix, m.path) for m in archive_request.mounts], + mounts=[ + MountArchiveSource( + mount_id=m.mount_id, + archive_prefix=m.prefix, + source_path=m.path, + ) + for m in archive_request.mounts + ], filename=archive_request.filename, ) diff --git a/api/oss/src/apis/fastapi/mounts/utils.py b/api/oss/src/apis/fastapi/mounts/utils.py index 2f78031672..5233815f65 100644 --- a/api/oss/src/apis/fastapi/mounts/utils.py +++ b/api/oss/src/apis/fastapi/mounts/utils.py @@ -2,7 +2,7 @@ from mimetypes import guess_type from posixpath import basename from stat import S_IFREG -from typing import List, Optional, Tuple +from typing import List, Optional from urllib.parse import quote from uuid import UUID @@ -10,7 +10,12 @@ from fastapi.responses import StreamingResponse from stream_zip import ZIP_AUTO, async_stream_zip -from oss.src.core.mounts.dtos import MountCredentials, MountFileWritten, MountQuery +from oss.src.core.mounts.dtos import ( + MountArchiveSource, + MountCredentials, + MountFileWritten, + MountQuery, +) from oss.src.core.mounts.service import MountsService # Regular-file mode for archive members (owner rw, group/other r). @@ -94,7 +99,7 @@ async def stream_mounts_archive( *, mounts_service: MountsService, project_id: UUID, - mounts: List[Tuple[UUID, str, str]], + mounts: List[MountArchiveSource], filename: str = "files.zip", ) -> StreamingResponse: """STREAM a zip of EVERY file across the given mounts as a binary download ("download all"). diff --git a/api/oss/src/core/mounts/dtos.py b/api/oss/src/core/mounts/dtos.py index 858e76c165..1425181c52 100644 --- a/api/oss/src/core/mounts/dtos.py +++ b/api/oss/src/core/mounts/dtos.py @@ -79,6 +79,16 @@ class MountFileList(BaseModel): total_capped: bool = False +class MountArchiveSource(BaseModel): + """One mount to include in a download-all archive: which mount, which folder within it + (`source_path`; "" = the whole mount), and the prefix its files sit under in the zip (the folded + drive layout).""" + + mount_id: UUID + source_path: str = "" + archive_prefix: str = "" + + class MountFileContent(BaseModel): path: str content: str diff --git a/api/oss/src/core/mounts/service.py b/api/oss/src/core/mounts/service.py index 86709e7ea5..02dd911445 100644 --- a/api/oss/src/core/mounts/service.py +++ b/api/oss/src/core/mounts/service.py @@ -12,6 +12,7 @@ from oss.src.core.workflows.service import WorkflowsService from oss.src.core.mounts.dtos import ( + MountArchiveSource, Mount, MountCreate, MountCredentials, @@ -1108,7 +1109,7 @@ async def build_archive_work_list( self, *, project_id: UUID, - mounts: List[Tuple[UUID, str, str]], + mounts: List[MountArchiveSource], ) -> List[Tuple[str, str, int, Optional[int]]]: """Build the ordered archive work list for the given mounts. @@ -1121,15 +1122,17 @@ async def build_archive_work_list( bucket = self._bucket() work: List[Tuple[str, str, int, Optional[int]]] = [] - for mount_id, prefix, source_path in mounts: - if prefix: - validate_file_path(prefix) - if source_path: - validate_file_path(source_path) - mount = await self._resolve_mount(project_id=project_id, mount_id=mount_id) + for source in mounts: + if source.archive_prefix: + validate_file_path(source.archive_prefix) + if source.source_path: + validate_file_path(source.source_path) + mount = await self._resolve_mount( + project_id=project_id, mount_id=source.mount_id + ) mount_base = self._storage_key(project_id=project_id, mount=mount) - pfx_segments = _safe_zip_segments(prefix) - src = source_path.strip("/") + pfx_segments = _safe_zip_segments(source.archive_prefix) + src = source.source_path.strip("/") # Scope the listing to a folder when `source_path` is set (folder download); the # rel path still keeps the folder, so the zip has "/…" entries. list_prefix = f"{mount_base}{src}/" if src else mount_base diff --git a/api/oss/tests/pytest/unit/test_mounts_file_ops.py b/api/oss/tests/pytest/unit/test_mounts_file_ops.py index 7566abb609..ad3476a3fb 100644 --- a/api/oss/tests/pytest/unit/test_mounts_file_ops.py +++ b/api/oss/tests/pytest/unit/test_mounts_file_ops.py @@ -19,7 +19,7 @@ from oss.src.apis.fastapi.mounts.utils import _content_disposition_attachment from oss.src.core.mounts import service as mounts_service_module -from oss.src.core.mounts.dtos import Mount, MountFile +from oss.src.core.mounts.dtos import Mount, MountArchiveSource, MountFile from oss.src.core.mounts.service import ( MountsService, _rollup_recent_entries, @@ -316,7 +316,7 @@ async def test_missing_mount_raises_during_work_list_build(self): with pytest.raises(MountNotFound): await service.build_archive_work_list( project_id=pid, - mounts=[(uuid4(), "", "")], + mounts=[MountArchiveSource(mount_id=uuid4())], ) async def test_zip_paths_include_mount_prefix(self): @@ -332,7 +332,7 @@ async def test_zip_paths_include_mount_prefix(self): work = await service.build_archive_work_list( project_id=pid, - mounts=[(mid, "prefix", "")], + mounts=[MountArchiveSource(mount_id=mid, archive_prefix="prefix")], ) assert {zip_path for zip_path, _key, _size, _mtime in work} == { @@ -357,7 +357,7 @@ async def _work_for_keys(self, keys): bucket_store[f"{mount_base}{key}"] = b"x" return await service.build_archive_work_list( project_id=mount.project_id, - mounts=[(mount.id, "", "")], + mounts=[MountArchiveSource(mount_id=mount.id)], ) async def test_traversal_keys_are_skipped_not_rewritten(self): From 086c0bc8054cbeed77671e1d87fb9cccab2b1283 Mon Sep 17 00:00:00 2001 From: Mahmoud Mabrouk Date: Mon, 20 Jul 2026 15:16:14 +0200 Subject: [PATCH 8/9] docs(api): document total's per-view meaning and soften archive scope `total` had three undocumented computed meanings; state the one contract explicitly (entries the queried view returns before any limit: leaf files in flat/recency modes, files-plus-folders in shallow/browse). Also stop the download-all docstring from promising >4 GB files, which are out of product scope. Claude-Session: https://claude.ai/code/session_01PTkjdoAPbhSEFR3ubpgzBU --- api/oss/src/apis/fastapi/mounts/models.py | 5 +++-- api/oss/src/apis/fastapi/mounts/utils.py | 4 ++-- api/oss/src/core/mounts/dtos.py | 6 ++++-- 3 files changed, 9 insertions(+), 6 deletions(-) diff --git a/api/oss/src/apis/fastapi/mounts/models.py b/api/oss/src/apis/fastapi/mounts/models.py index a31da1989c..c405e1efb0 100644 --- a/api/oss/src/apis/fastapi/mounts/models.py +++ b/api/oss/src/apis/fastapi/mounts/models.py @@ -75,8 +75,9 @@ class MountsResponse(BaseModel): class MountFileListResponse(BaseModel): count: int = 0 - # Full file count matching the request before any limit — lets a limited "latest N" listing - # still report the true total (the UI badge). Equals `count` for an unlimited listing. + # Entries this view would return BEFORE any limit — so a limited "latest N" listing still reports + # the true total (the UI badge). Its unit follows the view: leaf files only in the recency listing + # (order/limit set), files-plus-folders in the shallow (depth=1) and browse modes. total: int = 0 # `total` is a FLOOR (the count-only scan hit its cap) — the UI shows "N+". False when exact. total_capped: bool = False diff --git a/api/oss/src/apis/fastapi/mounts/utils.py b/api/oss/src/apis/fastapi/mounts/utils.py index 5233815f65..811c3b8221 100644 --- a/api/oss/src/apis/fastapi/mounts/utils.py +++ b/api/oss/src/apis/fastapi/mounts/utils.py @@ -106,8 +106,8 @@ async def stream_mounts_archive( The drive folds cwd + agent-files into one tree, so each ``(mount_id, prefix)`` is placed under ``prefix/`` in the zip. The archive is streamed member-by-member (never buffered whole), and the - service prefetches file bodies with bounded concurrency. ``ZIP_AUTO`` picks zip32/zip64 per file - by size, so large drives and >4 GB archives are handled. + service prefetches file bodies with bounded concurrency. ``ZIP_AUTO`` picks zip32/zip64 per entry + by size; very large individual files are out of product scope for "download all". """ work = await mounts_service.build_archive_work_list( project_id=project_id, diff --git a/api/oss/src/core/mounts/dtos.py b/api/oss/src/core/mounts/dtos.py index 1425181c52..3b0d89f4f7 100644 --- a/api/oss/src/core/mounts/dtos.py +++ b/api/oss/src/core/mounts/dtos.py @@ -71,8 +71,10 @@ class MountFile(BaseModel): class MountFileList(BaseModel): files: List[MountFile] = Field(default_factory=list) - # Total real files matching the request BEFORE any limit — so a limited "latest N" listing can - # still report the true file count (the UI badge) without shipping the whole tree. + # Entries this view would return BEFORE any limit — so a limited "latest N" listing still reports + # the true total (the UI badge) without shipping the whole tree. Its unit follows the view: leaf + # files only in the recency listing (order/limit set), files-plus-folders in the shallow (depth=1) + # and browse modes. total: int = 0 # `total` is a FLOOR, not exact — the count-only scan hit its cap on a very large tree, so the UI # shows "N+". False for an exhaustive count. From 0feba68d2dbb9cf5281fb0316303ef23f836ad7f Mon Sep 17 00:00:00 2001 From: Mahmoud Mabrouk Date: Mon, 20 Jul 2026 15:17:24 +0200 Subject: [PATCH 9/9] test(api): update path-validation test for the relaxed denylist test_mounts_service.py still asserted the old allowlist rejected angle brackets; under the denylist they are legitimate filename characters. Assert they (and route-group names) are accepted, and pin control-char rejection. Claude-Session: https://claude.ai/code/session_01PTkjdoAPbhSEFR3ubpgzBU --- api/oss/tests/pytest/unit/test_mounts_service.py | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/api/oss/tests/pytest/unit/test_mounts_service.py b/api/oss/tests/pytest/unit/test_mounts_service.py index 8b8c5ba15b..40a8b48415 100644 --- a/api/oss/tests/pytest/unit/test_mounts_service.py +++ b/api/oss/tests/pytest/unit/test_mounts_service.py @@ -95,6 +95,12 @@ def test_rejects_empty(self): with pytest.raises(MountPathInvalid): validate_file_path("/") - def test_rejects_angle_brackets(self): + def test_accepts_punctuation_real_filenames_use(self): + # Denylist, not allowlist: only traversal / control chars are unsafe, so real folder names + # keep working (route groups, npm scopes, angle brackets, etc.). + validate_file_path("path/") + validate_file_path("app/(auth)/[slug]/page.tsx") + + def test_rejects_control_char(self): with pytest.raises(MountPathInvalid): - validate_file_path("path/") + validate_file_path("path/a\x00b")