diff --git a/README.md b/README.md index ccc88f0ecf..0ef9001558 100644 --- a/README.md +++ b/README.md @@ -1941,6 +1941,9 @@ then provides the new revision for saving; an older preview still requires a rel Regular files over the 2 MiB editing limit use file identity and change metadata for range validators without hashing the whole file. Conditional byte ranges for regular files require a matching ETag; date-form `If-Range` requests receive the full current file. +Saving also bounds the existing-file read to 2 MiB plus one detection byte, so a +file that grows or is replaced after the size check is rejected without loading +the entire oversized file into memory. CSV and TSV artifacts open as tables in the artifact panel and in a separate window. The preview preserves text values (including leading zeros), supports an optional header row, and pages through up to 200 rows and 50 columns from the initial sample. Long or multiline cells can be opened and copied in full. Switch to source to inspect or edit the file; downloads and separate windows use the saved version. diff --git a/backend/app/gateway/AGENTS.md b/backend/app/gateway/AGENTS.md index b6d535e5a2..8c95003d05 100644 --- a/backend/app/gateway/AGENTS.md +++ b/backend/app/gateway/AGENTS.md @@ -223,6 +223,8 @@ Capability skill discovery (`/api/capabilities/installations/skills`) reuses It must preserve the ordinary skill listing's caller visibility and provider failure policy rather than exposing the unfiltered user-scoped catalog. +Cap edit reads at `MAX_EDITABLE_ARTIFACT_BYTES + 1` despite stale `lstat`. + ### Route and skill-listing authorization `authz.authorize_model_use` shares the `model:use` decision between model details diff --git a/backend/app/gateway/routers/artifacts.py b/backend/app/gateway/routers/artifacts.py index 321d87a335..f2f82ad321 100644 --- a/backend/app/gateway/routers/artifacts.py +++ b/backend/app/gateway/routers/artifacts.py @@ -88,7 +88,8 @@ def _load_editable_artifact(actual_path: Path, path: str, expected_sha256: str) if file_stat.st_size > MAX_EDITABLE_ARTIFACT_BYTES: raise HTTPException(status_code=413, detail="Artifact is too large to edit") - current = actual_path.read_bytes() + with actual_path.open("rb") as handle: + current = handle.read(MAX_EDITABLE_ARTIFACT_BYTES + 1) if len(current) > MAX_EDITABLE_ARTIFACT_BYTES: raise HTTPException(status_code=413, detail="Artifact is too large to edit") if b"\x00" in current: diff --git a/backend/tests/test_artifacts_router.py b/backend/tests/test_artifacts_router.py index 4d664d533f..e207512559 100644 --- a/backend/tests/test_artifacts_router.py +++ b/backend/tests/test_artifacts_router.py @@ -110,6 +110,73 @@ def _artifact_sha256(content: str) -> str: return hashlib.sha256(content.encode("utf-8")).hexdigest() +@pytest.mark.parametrize("mutation", ["grow", "replace"]) +def test_load_editable_artifact_bounds_read_after_size_probe(tmp_path, monkeypatch, mutation) -> None: + artifact_path = tmp_path / "note.txt" + artifact_path.write_bytes(b"before") + limit = artifacts_router.MAX_EDITABLE_ARTIFACT_BYTES + oversized = b"x" * (2 * limit) + replacement_path = tmp_path / "replacement.txt" + replacement_path.write_bytes(oversized) + original_lstat = os.lstat + original_open = Path.open + read_calls = [] + handles = [] + + def mutate_after_size_probe(path, *args, **kwargs): + file_stat = original_lstat(path, *args, **kwargs) + if Path(path) == artifact_path: + if mutation == "grow": + artifact_path.write_bytes(oversized) + else: + os.replace(replacement_path, artifact_path) + return file_stat + + class RecordingReader: + def __init__(self, handle): + self.handle = handle + + def __enter__(self): + self.handle.__enter__() + return self + + def __exit__(self, *args): + return self.handle.__exit__(*args) + + def read(self, size=-1): + content = self.handle.read(size) + read_calls.append((size, len(content))) + return content + + def record_open(path, mode="r", *args, **kwargs): + handle = original_open(path, mode, *args, **kwargs) + if path == artifact_path and mode == "rb": + handles.append(handle) + return RecordingReader(handle) + return handle + + monkeypatch.setattr(os, "lstat", mutate_after_size_probe) + monkeypatch.setattr(Path, "open", record_open) + with pytest.raises(HTTPException) as exc_info: + artifacts_router._load_editable_artifact(artifact_path, "mnt/user-data/outputs/note.txt", _artifact_sha256("before")) + + assert exc_info.value.status_code == 413 + assert read_calls == [(limit + 1, limit + 1)] + assert all(handle.closed for handle in handles) + + +@pytest.mark.parametrize("size", [0, 6, artifacts_router.MAX_EDITABLE_ARTIFACT_BYTES]) +def test_load_editable_artifact_accepts_text_up_to_size_limit(tmp_path, size) -> None: + artifact_path = tmp_path / "note.txt" + content = b"x" * size + artifact_path.write_bytes(content) + + loaded, file_stat = artifacts_router._load_editable_artifact(artifact_path, "mnt/user-data/outputs/note.txt", hashlib.sha256(content).hexdigest()) + + assert loaded == content + assert file_stat.st_size == size + + def _patch_artifact_update_dependencies(monkeypatch, artifact_path: Path, provider=None) -> None: monkeypatch.setattr(artifacts_router, "resolve_outputs_confined_path", lambda _thread_id, _path, user_id=None: artifact_path) monkeypatch.setattr(artifacts_router, "reserve_artifact_write", _allow_artifact_write)