fix(artifacts): bound reads before saving edited outputs (#6250)

This commit is contained in:
Daoyuan Li 2026-10-04 04:39:50 -07:00 committed by GitHub
parent 4dad94dcc0
commit a9046508cf
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 74 additions and 1 deletions

View File

@ -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.

View File

@ -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

View File

@ -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:

View File

@ -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)