mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-09-19 11:06:18 +00:00
* feat(projects): Projects MVP Phase 2 — instructions, document shelf, promotion, trash Implements docs/superpowers/specs/2026-09-12-projects-mvp-phase2-design.md (issue #5160, tracker #5129) in the slice order of the spec's §16. Slices: - A: ProjectsConfig + write-time 422 UTF-8 byte cap; PROJECT_CONTEXT_KEY admission pinning (both server-owned sets + worker hoist); latest-only request-scoped <project> block via DynamicContextMiddleware wrap_model_call/awrap_model_call (idempotent reassembly, reserved ID prefix + marker + provenance, never persisted); journal audit fingerprints; Instructions tab. - B: ProjectDocumentRow + migration 0023; ProjectDocumentRepository with locked check-and-set; hash-qualified immutable shelf storage with Paths helpers; upload/list/content/delete-to-trash routes; project delete trashes the shelf in-transaction; request-scoped bounded <documents> index with honest count/shown + actionable overflow note; list_project_documents/read_project_document tools registered only on pinned runs; PAT allowlist + drift guards; blocking-IO anchors. - C: shared thread-upload ingestion service (uploads router refactored to parity); POST from-thread with provenance; attach-to-thread with lock-staged copy (archived source allowed); read-only thread-files view with per-group truncation reporting. - D: restore (restored/merged/not_found/no_target/content_missing; no file moves), purge (continuous row lock across unlink/delete/commit, retryable on FS errors), retention sweep (lazy + startup, 24h orphan guard, row-side reconciliation never deletes). - E: Documents tab (shelf + conversation-files browser, provenance, archived banner, content-missing rows), /workspace/trash route, sidebar entry, composer attach handoff, i18n (en-US/zh-CN), e2e mocks + specs. Review hardening folded in (10 rounds, all with tests): - force active shelf content (HTML/XML family) to download; nosniff on artifact + content responses; unified unsandboxed-iframe PDF preview (fixes the pre-existing Chromium sandbox blank in the artifact viewer) - scope document trash to the URL project under the document lock - atomic no-overwrite filename reservation for ALL ingestion (seeded claims + os.link commit with suffix retry; same-name re-upload now unique-names instead of replacing); hidden staging only, no visible placeholders; lease cleanup on setup failure - serialize conversion under the document lock with post-lock active revalidation; drain locked filesystem work on cancellation; preserve bytes when an insert's commit state is uncertain (including trashed rows) - original-integrity checks before serving text or cached conversions; content_missing surfaced in list responses (UI reads the flag, no 409-probe); downloads always serve original bytes - bounded streaming document reads with cached char counts; shelf limits declared in middleware release identity - thread-root confinement for from-thread sources; config fallback rejects fractional/infinite values; composer counts staged attachments; pending attachments persist until submission or removal; in-flight instruction/rename edits survive save refetches; shelf and trash pagination; conversation-file and thread-files pages stay subscribed to refetches Docs: README/README_zh, backend API.md/ARCHITECTURE.md, AGENTS.md contracts, config.example.yaml projects block. Review follow-ups (head b4807477 → this revision): - The trash retention sweep is split so repeated lazy triggers stay bounded: the indexed expiry purge still runs on every trigger (GET /api/trash/documents, POST /api/trash/purge) while the O(all rows + all files) reconciliation is throttled to one run per user per 15 minutes (process-local, per-user window). The startup sweep now runs as a background task instead of blocking gateway readiness, and shutdown awaits it (bounded). - The export scrub (stripInternalMarkers) is fence- and indentation-aware like the render path, so a pasted, fenced <project>/<documents> snippet survives markdown export while real injected blocks (never fenced) are still removed. Fence regexes moved to a dependency-free leaf module to avoid the messages↔streamdown import cycle. - The artifact viewer's PDF iframe no longer carries an added title attribute (the upstream e2e contract locates it via :not([title])), and the upstream artifact-preview spec now pins the new contract: PDFs render unsandboxed, images keep sandbox="". * fix(projects): round-2 review — cancel an overrun trash sweep, restore the PDF frame title - Shutdown cancelled only the shield around the background startup sweep, so an all-users reconciliation that outlived the 5s budget kept walking rows and files while the document repo and DB engine were disposed underneath it. The wait now lives in `_shutdown_startup_trash_sweep`, which cancels the task and drains it before worker exit: the shield keeps the wait bounded, the cancel makes it final (CancelledError lands at the sweep's next await, and `_run_startup_trash_sweep` only catches `Exception`, so nothing swallows it). - The browser-preview iframe lost `title={getFileName(filepath)}` in the previous fix round, leaving the PDF frame without an accessible name while its siblings keep theirs. Restore it (WCAG frame titles), assert it in the DOM test, and anchor the e2e on `iframe[title="report.pdf"]` instead of `iframe:not([title])`. * fix(projects): round-3 review — report the sweep's late finish, not a phantom cancel `Task.cancel()` returns False when the sweep already finished inside the window between the deadline firing and the cancel, so the shutdown log claimed a cancellation that never happened. Branch on that outcome: the warning stays for a real cancel, a late finish is logged at info, and both paths still reap the task before worker exit. * fix(projects): round-4 review — make Empty trash delete what it confirms `POST /api/trash/purge` only ran the retention sweep, and the sweep's candidate selection is age-gated, so a freshly trashed document survived "Empty trash" even though the confirmation promises that every listed document is permanently deleted. With one trashed row the route answered `{"purged": 0}` and left it in place; `GET /api/trash/documents` sweeps expired rows before listing, so the visible rows were normally ineligible for the action by construction. Empty trash now drives `purge_all_trashed`: the caller's trashed rows (`list_all_trashed`, no age filter) each go through the same guarded, row-locked `purge` as the single-document delete — bytes first, then the row, in one transaction — so a row restored mid-flight is skipped instead of force-deleted, and an unlink failure rolls that row back and answers 500 with a retryable message. Retention expiry stays where it was: the sweep's `purge_candidates` is now the only age-gated selection, and the lazy retention sweep still runs on the listing and at startup. Tests: the router suite replaces the retention-gated expectation with the reviewer's repro (fresh row purged, bytes unlinked, shelf and other users' trash untouched, a failing unlink stays retryable and 500); a blocking-I/O anchor drives the new entry point through the offload; the mocked e2e covers the action end to end; a new real-backend spec performs it against the real gateway and re-reads `GET /api/trash/documents`. README, API, ARCHITECTURE and the phase-2 design docs (en+zh) state the age-independent contract.
910 lines
38 KiB
Python
910 lines
38 KiB
Python
import asyncio
|
|
import hashlib
|
|
import stat
|
|
import zipfile
|
|
from contextlib import asynccontextmanager
|
|
from pathlib import Path
|
|
from types import SimpleNamespace
|
|
|
|
import pytest
|
|
from _router_auth_helpers import call_unwrapped, make_authed_test_app
|
|
from fastapi import HTTPException
|
|
from fastapi.testclient import TestClient
|
|
from starlette.requests import Request
|
|
from starlette.responses import FileResponse
|
|
|
|
import app.gateway.routers.artifacts as artifacts_router
|
|
from app.gateway.internal_auth import INTERNAL_OWNER_USER_ID_HEADER_NAME, INTERNAL_SYSTEM_ROLE
|
|
from deerflow.config.paths import Paths, make_safe_user_id
|
|
from deerflow.sandbox.lease import get_sandbox_lease_manager
|
|
|
|
# Browsers render any XML MIME type as a document, so an XHTML-namespaced
|
|
# script in a plain .xml file runs in the application origin as well.
|
|
XHTML_SCRIPT_XML = '<?xml version="1.0"?><html xmlns="http://www.w3.org/1999/xhtml"><script>alert("xss")</script></html>'
|
|
|
|
ACTIVE_ARTIFACT_CASES = [
|
|
("poc.html", "<html><body><script>alert('xss')</script></body></html>"),
|
|
("page.xhtml", '<?xml version="1.0"?><html xmlns="http://www.w3.org/1999/xhtml"><body>hello</body></html>'),
|
|
("image.svg", '<svg xmlns="http://www.w3.org/2000/svg"><script>alert("xss")</script></svg>'),
|
|
("report.xml", XHTML_SCRIPT_XML),
|
|
("transform.xsl", XHTML_SCRIPT_XML),
|
|
("graph.rdf", XHTML_SCRIPT_XML),
|
|
]
|
|
|
|
|
|
def _make_request(query_string: bytes = b"") -> Request:
|
|
return Request({"type": "http", "method": "GET", "path": "/", "headers": [], "query_string": query_string})
|
|
|
|
|
|
def test_get_artifact_reads_utf8_text_file_on_windows_locale(tmp_path, monkeypatch) -> None:
|
|
artifact_path = tmp_path / "note.txt"
|
|
text = "Curly quotes: \u201cutf8\u201d"
|
|
artifact_path.write_text(text, encoding="utf-8")
|
|
|
|
original_read_text = Path.read_text
|
|
|
|
def reject_artifact_read_text(self, *args, **kwargs):
|
|
if self == artifact_path:
|
|
pytest.fail("text files must stream")
|
|
return original_read_text(self, *args, **kwargs)
|
|
|
|
monkeypatch.setattr(Path, "read_text", reject_artifact_read_text)
|
|
monkeypatch.setattr(artifacts_router, "resolve_thread_virtual_path", lambda _thread_id, _path, user_id=None: artifact_path)
|
|
|
|
app = make_authed_test_app()
|
|
app.include_router(artifacts_router.router)
|
|
with TestClient(app) as client:
|
|
response = client.get("/api/threads/thread-1/artifacts/mnt/user-data/outputs/note.txt")
|
|
|
|
assert response.text == text
|
|
assert response.headers["content-type"].startswith("text/plain")
|
|
assert response.headers["accept-ranges"] == "bytes"
|
|
|
|
|
|
@asynccontextmanager
|
|
async def _allow_artifact_write(*_args, **_kwargs):
|
|
yield
|
|
|
|
|
|
class _MountedSandboxProvider:
|
|
uses_thread_data_mounts = True
|
|
|
|
|
|
class _RemoteSandbox:
|
|
def __init__(self, *, fail_next_update: bool = False) -> None:
|
|
self.updates: list[tuple[str, bytes]] = []
|
|
self.fail_next_update = fail_next_update
|
|
self.released_scopes: list[str] = []
|
|
|
|
def update_file(self, path: str, content: bytes) -> None:
|
|
if self.fail_next_update:
|
|
self.fail_next_update = False
|
|
raise RuntimeError("sandbox sync failed")
|
|
self.updates.append((path, content))
|
|
|
|
def release_command_scope(self, scope_id: str) -> None:
|
|
self.released_scopes.append(scope_id)
|
|
|
|
|
|
class _RemoteSandboxProvider:
|
|
uses_thread_data_mounts = False
|
|
|
|
def __init__(self, *, fail_next_update: bool = False) -> None:
|
|
self.sandbox = _RemoteSandbox(fail_next_update=fail_next_update)
|
|
self.released: list[str] = []
|
|
|
|
async def acquire_async(self, _thread_id: str, *, user_id: str | None = None) -> str:
|
|
return "sandbox-1"
|
|
|
|
def get(self, sandbox_id: str):
|
|
assert sandbox_id == "sandbox-1"
|
|
return self.sandbox
|
|
|
|
def release(self, sandbox_id: str) -> None:
|
|
self.released.append(sandbox_id)
|
|
|
|
|
|
def _artifact_sha256(content: str) -> str:
|
|
return hashlib.sha256(content.encode("utf-8")).hexdigest()
|
|
|
|
|
|
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)
|
|
monkeypatch.setattr(artifacts_router, "get_sandbox_provider", lambda: provider or _MountedSandboxProvider())
|
|
|
|
|
|
def test_update_artifact_replaces_utf8_text_atomically(tmp_path, monkeypatch) -> None:
|
|
artifact_path = tmp_path / "note.txt"
|
|
artifact_path.write_text("before", encoding="utf-8")
|
|
artifact_path.chmod(0o600)
|
|
_patch_artifact_update_dependencies(monkeypatch, artifact_path)
|
|
|
|
response = asyncio.run(
|
|
call_unwrapped(
|
|
artifacts_router.update_artifact,
|
|
"thread-1",
|
|
"mnt/user-data/outputs/note.txt",
|
|
artifacts_router.ArtifactUpdateRequest(content="after", expected_sha256=_artifact_sha256("before")),
|
|
_make_request(),
|
|
)
|
|
)
|
|
|
|
assert artifact_path.read_text(encoding="utf-8") == "after"
|
|
assert response.path == "/mnt/user-data/outputs/note.txt"
|
|
assert response.sha256 == _artifact_sha256("after")
|
|
assert response.size == len(b"after")
|
|
if hasattr(artifacts_router.os, "fchmod"):
|
|
replacement_mode = stat.S_IMODE(artifact_path.stat().st_mode)
|
|
assert replacement_mode == 0o660
|
|
assert not replacement_mode & stat.S_IWOTH
|
|
|
|
|
|
def test_update_artifact_replaces_when_fchmod_is_unavailable(tmp_path, monkeypatch) -> None:
|
|
artifact_path = tmp_path / "note.txt"
|
|
artifact_path.write_text("before", encoding="utf-8")
|
|
_patch_artifact_update_dependencies(monkeypatch, artifact_path)
|
|
monkeypatch.delattr(artifacts_router.os, "fchmod", raising=False)
|
|
|
|
response = asyncio.run(
|
|
call_unwrapped(
|
|
artifacts_router.update_artifact,
|
|
"thread-1",
|
|
"mnt/user-data/outputs/note.txt",
|
|
artifacts_router.ArtifactUpdateRequest(content="after", expected_sha256=_artifact_sha256("before")),
|
|
_make_request(),
|
|
)
|
|
)
|
|
|
|
assert artifact_path.read_text(encoding="utf-8") == "after"
|
|
assert response.sha256 == _artifact_sha256("after")
|
|
|
|
|
|
def test_update_artifact_rejects_stale_revision_without_changing_file(tmp_path, monkeypatch) -> None:
|
|
artifact_path = tmp_path / "note.txt"
|
|
artifact_path.write_text("agent version", encoding="utf-8")
|
|
_patch_artifact_update_dependencies(monkeypatch, artifact_path)
|
|
|
|
with pytest.raises(HTTPException) as exc_info:
|
|
asyncio.run(
|
|
call_unwrapped(
|
|
artifacts_router.update_artifact,
|
|
"thread-1",
|
|
"mnt/user-data/outputs/note.txt",
|
|
artifacts_router.ArtifactUpdateRequest(content="user version", expected_sha256=_artifact_sha256("old version")),
|
|
_make_request(),
|
|
)
|
|
)
|
|
|
|
assert exc_info.value.status_code == 412
|
|
assert artifact_path.read_text(encoding="utf-8") == "agent version"
|
|
|
|
|
|
def test_update_artifact_rejects_non_output_path(tmp_path, monkeypatch) -> None:
|
|
artifact_path = tmp_path / "note.txt"
|
|
artifact_path.write_text("before", encoding="utf-8")
|
|
_patch_artifact_update_dependencies(monkeypatch, artifact_path)
|
|
|
|
with pytest.raises(HTTPException) as exc_info:
|
|
asyncio.run(
|
|
call_unwrapped(
|
|
artifacts_router.update_artifact,
|
|
"thread-1",
|
|
"mnt/user-data/workspace/note.txt",
|
|
artifacts_router.ArtifactUpdateRequest(content="after", expected_sha256=_artifact_sha256("before")),
|
|
_make_request(),
|
|
)
|
|
)
|
|
|
|
assert exc_info.value.status_code == 400
|
|
assert artifact_path.read_text(encoding="utf-8") == "before"
|
|
|
|
|
|
_REAL_PATHS_THREAD_ID = "thread-1"
|
|
_REAL_PATHS_USER_ID = "user-1"
|
|
|
|
|
|
def _patch_real_thread_paths(monkeypatch, tmp_path: Path, provider=None) -> tuple[Path, Path]:
|
|
"""Route ``update_artifact`` through the real virtual-path resolver rooted at *tmp_path*.
|
|
|
|
The other update tests stub ``resolve_outputs_confined_path`` so they never
|
|
exercise the outputs confinement; these tests need the real thread layout.
|
|
Returns the thread's ``outputs`` and ``uploads`` host directories.
|
|
"""
|
|
paths = Paths(tmp_path)
|
|
monkeypatch.setattr("app.gateway.path_utils.get_paths", lambda: paths)
|
|
monkeypatch.setattr(artifacts_router, "get_effective_user_id", lambda: _REAL_PATHS_USER_ID)
|
|
monkeypatch.setattr(artifacts_router, "get_trusted_internal_owner_user_id", lambda _request: None)
|
|
monkeypatch.setattr(artifacts_router, "reserve_artifact_write", _allow_artifact_write)
|
|
monkeypatch.setattr(artifacts_router, "get_sandbox_provider", lambda: provider or _MountedSandboxProvider())
|
|
|
|
outputs = paths.sandbox_outputs_dir(_REAL_PATHS_THREAD_ID, user_id=_REAL_PATHS_USER_ID)
|
|
uploads = paths.sandbox_uploads_dir(_REAL_PATHS_THREAD_ID, user_id=_REAL_PATHS_USER_ID)
|
|
outputs.mkdir(parents=True)
|
|
uploads.mkdir(parents=True)
|
|
return outputs, uploads
|
|
|
|
|
|
def _update_artifact_via_handler(path: str, *, current: str, content: str):
|
|
return asyncio.run(
|
|
call_unwrapped(
|
|
artifacts_router.update_artifact,
|
|
_REAL_PATHS_THREAD_ID,
|
|
path,
|
|
artifacts_router.ArtifactUpdateRequest(content=content, expected_sha256=_artifact_sha256(current)),
|
|
_make_request(),
|
|
)
|
|
)
|
|
|
|
|
|
def test_update_artifact_rejects_dot_dot_escape_from_outputs(tmp_path, monkeypatch) -> None:
|
|
# The outputs-only guard used to be a string-prefix check on the raw path,
|
|
# so ``outputs/../uploads/...`` passed it and the resolver only confines to
|
|
# ``user-data/`` — letting PUT overwrite a sibling upload.
|
|
_, uploads = _patch_real_thread_paths(monkeypatch, tmp_path)
|
|
victim = uploads / "victim.txt"
|
|
victim.write_text("before", encoding="utf-8")
|
|
|
|
with pytest.raises(HTTPException) as exc_info:
|
|
_update_artifact_via_handler("mnt/user-data/outputs/../uploads/victim.txt", current="before", content="after")
|
|
|
|
assert exc_info.value.status_code == 400
|
|
assert victim.read_text(encoding="utf-8") == "before"
|
|
|
|
|
|
def test_update_artifact_rejects_percent_encoded_dot_dot_over_http(tmp_path, monkeypatch) -> None:
|
|
# Browsers and HTTP clients collapse a literal ``..`` before sending, but
|
|
# ``%2e%2e`` reaches the route intact and Starlette decodes it to ``..``.
|
|
_, uploads = _patch_real_thread_paths(monkeypatch, tmp_path)
|
|
victim = uploads / "victim.txt"
|
|
victim.write_text("before", encoding="utf-8")
|
|
|
|
app = make_authed_test_app()
|
|
app.include_router(artifacts_router.router)
|
|
with TestClient(app) as client:
|
|
response = client.put(
|
|
f"/api/threads/{_REAL_PATHS_THREAD_ID}/artifacts/mnt/user-data/outputs/%2e%2e/uploads/victim.txt",
|
|
json={"content": "after", "expected_sha256": _artifact_sha256("before")},
|
|
)
|
|
|
|
assert response.status_code == 400
|
|
assert victim.read_text(encoding="utf-8") == "before"
|
|
|
|
|
|
def test_update_artifact_rejects_symlink_escaping_outputs(tmp_path, monkeypatch) -> None:
|
|
outputs, uploads = _patch_real_thread_paths(monkeypatch, tmp_path)
|
|
victim = uploads / "victim.txt"
|
|
victim.write_text("before", encoding="utf-8")
|
|
link = outputs / "linked.txt"
|
|
try:
|
|
link.symlink_to(victim)
|
|
except OSError:
|
|
pytest.skip("symlinks are unavailable on this platform")
|
|
|
|
with pytest.raises(HTTPException) as exc_info:
|
|
_update_artifact_via_handler("mnt/user-data/outputs/linked.txt", current="before", content="after")
|
|
|
|
assert exc_info.value.status_code == 400
|
|
assert victim.read_text(encoding="utf-8") == "before"
|
|
|
|
|
|
def test_update_artifact_normalizes_dot_segments_before_syncing(tmp_path, monkeypatch) -> None:
|
|
provider = _RemoteSandboxProvider()
|
|
outputs, _ = _patch_real_thread_paths(monkeypatch, tmp_path, provider=provider)
|
|
artifact_path = outputs / "note.txt"
|
|
artifact_path.write_text("before", encoding="utf-8")
|
|
|
|
response = _update_artifact_via_handler("mnt/user-data/outputs/./nested/../note.txt", current="before", content="after")
|
|
|
|
assert artifact_path.read_text(encoding="utf-8") == "after"
|
|
assert response.path == "/mnt/user-data/outputs/note.txt"
|
|
assert provider.sandbox.updates == [("/mnt/user-data/outputs/note.txt", b"after")]
|
|
|
|
|
|
def test_update_artifact_rejects_binary_file(tmp_path, monkeypatch) -> None:
|
|
artifact_path = tmp_path / "blob.bin"
|
|
artifact_path.write_bytes(b"before\x00binary")
|
|
_patch_artifact_update_dependencies(monkeypatch, artifact_path)
|
|
|
|
with pytest.raises(HTTPException) as exc_info:
|
|
asyncio.run(
|
|
call_unwrapped(
|
|
artifacts_router.update_artifact,
|
|
"thread-1",
|
|
"mnt/user-data/outputs/blob.bin",
|
|
artifacts_router.ArtifactUpdateRequest(content="after", expected_sha256=hashlib.sha256(b"before\x00binary").hexdigest()),
|
|
_make_request(),
|
|
)
|
|
)
|
|
|
|
assert exc_info.value.status_code == 415
|
|
|
|
|
|
def test_update_artifact_syncs_non_mounted_sandbox(tmp_path, monkeypatch) -> None:
|
|
artifact_path = tmp_path / "note.txt"
|
|
artifact_path.write_text("before", encoding="utf-8")
|
|
provider = _RemoteSandboxProvider()
|
|
_patch_artifact_update_dependencies(monkeypatch, artifact_path, provider)
|
|
|
|
asyncio.run(
|
|
call_unwrapped(
|
|
artifacts_router.update_artifact,
|
|
"thread-1",
|
|
"mnt/user-data/outputs/note.txt",
|
|
artifacts_router.ArtifactUpdateRequest(content="after", expected_sha256=_artifact_sha256("before")),
|
|
_make_request(),
|
|
)
|
|
)
|
|
|
|
assert provider.sandbox.updates == [("/mnt/user-data/outputs/note.txt", b"after")]
|
|
assert provider.released == ["sandbox-1"]
|
|
assert artifact_path.read_text(encoding="utf-8") == "after"
|
|
|
|
|
|
def test_update_artifact_does_not_release_under_active_execution_lease(tmp_path, monkeypatch) -> None:
|
|
artifact_path = tmp_path / "note.txt"
|
|
artifact_path.write_text("before", encoding="utf-8")
|
|
provider = _RemoteSandboxProvider()
|
|
manager = get_sandbox_lease_manager(provider)
|
|
manager.retain(
|
|
"active-agent",
|
|
"sandbox-1",
|
|
thread_id="thread-1",
|
|
user_id="default",
|
|
)
|
|
_patch_artifact_update_dependencies(monkeypatch, artifact_path, provider)
|
|
|
|
asyncio.run(
|
|
call_unwrapped(
|
|
artifacts_router.update_artifact,
|
|
"thread-1",
|
|
"mnt/user-data/outputs/note.txt",
|
|
artifacts_router.ArtifactUpdateRequest(content="after", expected_sha256=_artifact_sha256("before")),
|
|
_make_request(),
|
|
)
|
|
)
|
|
|
|
assert provider.sandbox.updates == [("/mnt/user-data/outputs/note.txt", b"after")]
|
|
assert manager.binding_for("active-agent") == "sandbox-1"
|
|
assert provider.released == []
|
|
|
|
manager.release("active-agent")
|
|
assert provider.released == ["sandbox-1"]
|
|
|
|
|
|
def test_update_artifact_releases_sandbox_when_initial_sync_fails(tmp_path, monkeypatch) -> None:
|
|
artifact_path = tmp_path / "note.txt"
|
|
artifact_path.write_text("before", encoding="utf-8")
|
|
provider = _RemoteSandboxProvider(fail_next_update=True)
|
|
_patch_artifact_update_dependencies(monkeypatch, artifact_path, provider)
|
|
|
|
with pytest.raises(HTTPException) as exc_info:
|
|
asyncio.run(
|
|
call_unwrapped(
|
|
artifacts_router.update_artifact,
|
|
"thread-1",
|
|
"mnt/user-data/outputs/note.txt",
|
|
artifacts_router.ArtifactUpdateRequest(content="after", expected_sha256=_artifact_sha256("before")),
|
|
_make_request(),
|
|
)
|
|
)
|
|
|
|
assert exc_info.value.status_code == 500
|
|
assert provider.released == ["sandbox-1"]
|
|
assert provider.sandbox.updates == [("/mnt/user-data/outputs/note.txt", b"before")]
|
|
assert artifact_path.read_text(encoding="utf-8") == "before"
|
|
|
|
|
|
def test_update_artifact_rolls_back_remote_when_local_replace_fails(tmp_path, monkeypatch) -> None:
|
|
artifact_path = tmp_path / "note.txt"
|
|
artifact_path.write_text("before", encoding="utf-8")
|
|
provider = _RemoteSandboxProvider()
|
|
_patch_artifact_update_dependencies(monkeypatch, artifact_path, provider)
|
|
|
|
def fail_replace(*_args, **_kwargs) -> None:
|
|
raise OSError("replace failed")
|
|
|
|
monkeypatch.setattr(artifacts_router, "_replace_artifact_atomically", fail_replace)
|
|
|
|
with pytest.raises(HTTPException) as exc_info:
|
|
asyncio.run(
|
|
call_unwrapped(
|
|
artifacts_router.update_artifact,
|
|
"thread-1",
|
|
"mnt/user-data/outputs/note.txt",
|
|
artifacts_router.ArtifactUpdateRequest(content="after", expected_sha256=_artifact_sha256("before")),
|
|
_make_request(),
|
|
)
|
|
)
|
|
|
|
assert exc_info.value.status_code == 500
|
|
assert provider.sandbox.updates == [
|
|
("/mnt/user-data/outputs/note.txt", b"after"),
|
|
("/mnt/user-data/outputs/note.txt", b"before"),
|
|
]
|
|
assert provider.released == ["sandbox-1"]
|
|
assert artifact_path.read_text(encoding="utf-8") == "before"
|
|
|
|
|
|
def test_update_artifact_rejects_oversized_content(tmp_path, monkeypatch) -> None:
|
|
artifact_path = tmp_path / "note.txt"
|
|
artifact_path.write_text("before", encoding="utf-8")
|
|
_patch_artifact_update_dependencies(monkeypatch, artifact_path)
|
|
|
|
with pytest.raises(HTTPException) as exc_info:
|
|
asyncio.run(
|
|
call_unwrapped(
|
|
artifacts_router.update_artifact,
|
|
"thread-1",
|
|
"mnt/user-data/outputs/note.txt",
|
|
artifacts_router.ArtifactUpdateRequest(
|
|
content="x" * (artifacts_router.MAX_EDITABLE_ARTIFACT_BYTES + 1),
|
|
expected_sha256=_artifact_sha256("before"),
|
|
),
|
|
_make_request(),
|
|
)
|
|
)
|
|
|
|
assert exc_info.value.status_code == 413
|
|
assert artifact_path.read_text(encoding="utf-8") == "before"
|
|
|
|
|
|
def test_update_artifact_reports_active_run_conflict(tmp_path, monkeypatch) -> None:
|
|
artifact_path = tmp_path / "note.txt"
|
|
artifact_path.write_text("before", encoding="utf-8")
|
|
monkeypatch.setattr(artifacts_router, "resolve_thread_virtual_path", lambda _thread_id, _path, user_id=None: artifact_path)
|
|
|
|
@asynccontextmanager
|
|
async def reject_artifact_write(*_args, **_kwargs):
|
|
raise artifacts_router.ConflictError("active run")
|
|
yield
|
|
|
|
monkeypatch.setattr(artifacts_router, "reserve_artifact_write", reject_artifact_write)
|
|
|
|
with pytest.raises(HTTPException) as exc_info:
|
|
asyncio.run(
|
|
call_unwrapped(
|
|
artifacts_router.update_artifact,
|
|
"thread-1",
|
|
"mnt/user-data/outputs/note.txt",
|
|
artifacts_router.ArtifactUpdateRequest(content="after", expected_sha256=_artifact_sha256("before")),
|
|
_make_request(),
|
|
)
|
|
)
|
|
|
|
assert exc_info.value.status_code == 409
|
|
assert artifact_path.read_text(encoding="utf-8") == "before"
|
|
|
|
|
|
def test_get_artifact_text_preview_supports_bounded_range_requests(tmp_path, monkeypatch) -> None:
|
|
payload = ("0123456789abcdef" * 131_072).encode()
|
|
artifact_path = tmp_path / "large.txt"
|
|
artifact_path.write_bytes(payload)
|
|
monkeypatch.setattr(artifacts_router, "resolve_thread_virtual_path", lambda _thread_id, _path, user_id=None: artifact_path)
|
|
|
|
app = make_authed_test_app()
|
|
app.include_router(artifacts_router.router)
|
|
with TestClient(app) as client:
|
|
preview = client.get(
|
|
"/api/threads/thread-1/artifacts/mnt/user-data/outputs/large.txt",
|
|
headers={"Range": "bytes=0-1048575"},
|
|
)
|
|
invalid = client.get(
|
|
"/api/threads/thread-1/artifacts/mnt/user-data/outputs/large.txt",
|
|
headers={"Range": f"bytes={len(payload)}-"},
|
|
)
|
|
|
|
assert preview.status_code == 206
|
|
assert preview.content == payload[:1_048_576]
|
|
assert preview.headers["content-range"] == f"bytes 0-1048575/{len(payload)}"
|
|
assert preview.headers["content-disposition"].startswith("inline;")
|
|
assert preview.headers["x-content-type-options"] == "nosniff"
|
|
assert invalid.status_code == 416
|
|
assert invalid.headers["content-range"] == f"bytes */{len(payload)}"
|
|
|
|
|
|
def test_get_artifact_inline_text_returns_sha256_etag(tmp_path, monkeypatch) -> None:
|
|
payload = b"hello artifact world"
|
|
artifact_path = tmp_path / "note.txt"
|
|
artifact_path.write_bytes(payload)
|
|
monkeypatch.setattr(artifacts_router, "resolve_thread_virtual_path", lambda _thread_id, _path, user_id=None: artifact_path)
|
|
|
|
app = make_authed_test_app()
|
|
app.include_router(artifacts_router.router)
|
|
with TestClient(app) as client:
|
|
response = client.get("/api/threads/thread-1/artifacts/mnt/user-data/outputs/note.txt")
|
|
|
|
assert response.status_code == 200
|
|
expected = hashlib.sha256(payload).hexdigest()
|
|
assert response.headers.get("etag") == f'"{expected}"'
|
|
|
|
|
|
def test_get_skill_archive_preview_supports_bounded_range_requests(tmp_path, monkeypatch) -> None:
|
|
payload = ("skill preview \u4e2d\u6587\n" * 100_000).encode()
|
|
skill_path = tmp_path / "sample.skill"
|
|
with zipfile.ZipFile(skill_path, "w", compression=zipfile.ZIP_DEFLATED) as zip_ref:
|
|
zip_ref.writestr("SKILL.md", payload)
|
|
|
|
monkeypatch.setattr(artifacts_router, "resolve_thread_virtual_path", lambda _thread_id, _path, user_id=None: skill_path)
|
|
|
|
app = make_authed_test_app()
|
|
app.include_router(artifacts_router.router)
|
|
with TestClient(app) as client:
|
|
preview = client.get(
|
|
"/api/threads/thread-1/artifacts/mnt/user-data/outputs/sample.skill/SKILL.md",
|
|
headers={"Range": "bytes=0-1048575"},
|
|
)
|
|
invalid = client.get(
|
|
"/api/threads/thread-1/artifacts/mnt/user-data/outputs/sample.skill/SKILL.md",
|
|
headers={"Range": f"bytes={len(payload)}-"},
|
|
)
|
|
|
|
assert preview.status_code == 206
|
|
assert preview.content == payload[:1_048_576]
|
|
assert preview.headers["accept-ranges"] == "bytes"
|
|
assert preview.headers["content-range"] == f"bytes 0-1048575/{len(payload)}"
|
|
assert invalid.status_code == 416
|
|
assert invalid.headers["content-range"] == f"bytes */{len(payload)}"
|
|
|
|
|
|
def test_get_skill_archive_inline_returns_sha256_etag(tmp_path, monkeypatch) -> None:
|
|
payload = ("skill preview \u4e2d\u6587\n" * 100).encode()
|
|
skill_path = tmp_path / "sample.skill"
|
|
with zipfile.ZipFile(skill_path, "w", compression=zipfile.ZIP_DEFLATED) as zip_ref:
|
|
zip_ref.writestr("SKILL.md", payload)
|
|
|
|
monkeypatch.setattr(artifacts_router, "resolve_thread_virtual_path", lambda _thread_id, _path, user_id=None: skill_path)
|
|
|
|
app = make_authed_test_app()
|
|
app.include_router(artifacts_router.router)
|
|
with TestClient(app) as client:
|
|
response = client.get("/api/threads/thread-1/artifacts/mnt/user-data/outputs/sample.skill/SKILL.md")
|
|
|
|
assert response.status_code == 200
|
|
expected = hashlib.sha256(payload).hexdigest()
|
|
assert response.headers.get("etag") == f'"{expected}"'
|
|
assert response.headers["x-content-type-options"] == "nosniff"
|
|
|
|
|
|
@pytest.mark.parametrize(("filename", "content"), ACTIVE_ARTIFACT_CASES)
|
|
def test_get_artifact_forces_download_for_active_content(tmp_path, monkeypatch, filename: str, content: str) -> None:
|
|
artifact_path = tmp_path / filename
|
|
artifact_path.write_text(content, encoding="utf-8")
|
|
|
|
monkeypatch.setattr(artifacts_router, "resolve_thread_virtual_path", lambda _thread_id, _path, user_id=None: artifact_path)
|
|
|
|
response = asyncio.run(call_unwrapped(artifacts_router.get_artifact, "thread-1", f"mnt/user-data/outputs/{filename}", _make_request()))
|
|
|
|
assert isinstance(response, FileResponse)
|
|
assert response.headers.get("content-disposition", "").startswith("attachment;")
|
|
assert response.headers.get("x-content-type-options") == "nosniff"
|
|
# The forced-download branch must carry a real SHA-256 ETag so the
|
|
# frontend can enable inline editing (see issue #4864 review feedback).
|
|
assert response.headers.get("etag") == f'"{hashlib.sha256(content.encode()).hexdigest()}"'
|
|
|
|
|
|
@pytest.mark.parametrize(("filename", "content"), ACTIVE_ARTIFACT_CASES)
|
|
def test_get_artifact_forces_download_for_active_content_in_skill_archive(tmp_path, monkeypatch, filename: str, content: str) -> None:
|
|
skill_path = tmp_path / "sample.skill"
|
|
with zipfile.ZipFile(skill_path, "w") as zip_ref:
|
|
zip_ref.writestr(filename, content)
|
|
|
|
monkeypatch.setattr(artifacts_router, "resolve_thread_virtual_path", lambda _thread_id, _path, user_id=None: skill_path)
|
|
|
|
response = asyncio.run(call_unwrapped(artifacts_router.get_artifact, "thread-1", f"mnt/user-data/outputs/sample.skill/{filename}", _make_request()))
|
|
|
|
assert response.headers.get("content-disposition", "").startswith("attachment;")
|
|
assert response.headers.get("x-content-type-options") == "nosniff"
|
|
assert bytes(response.body) == content.encode("utf-8")
|
|
|
|
|
|
@pytest.mark.parametrize("in_skill_archive", [False, True])
|
|
def test_get_artifact_forces_download_for_any_xml_subtype(tmp_path, monkeypatch, in_skill_archive: bool) -> None:
|
|
# Whether .rss guesses to application/rss+xml depends on the host's
|
|
# mime.types file, so pin the guess to exercise the +xml rule on both paths.
|
|
content = '<?xml version="1.0"?><rss><x:script xmlns:x="http://www.w3.org/1999/xhtml">alert("xss")</x:script></rss>'
|
|
monkeypatch.setattr(artifacts_router.mimetypes, "guess_type", lambda *_args, **_kwargs: ("application/rss+xml", None))
|
|
if in_skill_archive:
|
|
artifact_path = tmp_path / "sample.skill"
|
|
with zipfile.ZipFile(artifact_path, "w") as zip_ref:
|
|
zip_ref.writestr("feed.rss", content)
|
|
path = "mnt/user-data/outputs/sample.skill/feed.rss"
|
|
else:
|
|
artifact_path = tmp_path / "feed.rss"
|
|
artifact_path.write_text(content, encoding="utf-8")
|
|
path = "mnt/user-data/outputs/feed.rss"
|
|
monkeypatch.setattr(artifacts_router, "resolve_thread_virtual_path", lambda _thread_id, _path, user_id=None: artifact_path)
|
|
|
|
response = asyncio.run(call_unwrapped(artifacts_router.get_artifact, "thread-1", path, _make_request()))
|
|
|
|
assert response.headers.get("content-disposition", "").startswith("attachment;")
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"mime_type",
|
|
[
|
|
"text/html",
|
|
"application/xhtml+xml",
|
|
"image/svg+xml",
|
|
"text/xml",
|
|
"application/xml",
|
|
"text/xsl",
|
|
"application/rss+xml",
|
|
"application/atom+xml",
|
|
"application/xslt+xml",
|
|
"TEXT/XML",
|
|
],
|
|
)
|
|
def test_is_active_content_mime_type_covers_html_and_xml_documents(mime_type: str) -> None:
|
|
# Whether .rss or .atom guess to a +xml type depends on the host's
|
|
# mime.types file, so the classification is pinned on MIME types directly.
|
|
assert artifacts_router._is_active_content_mime_type(mime_type)
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"mime_type",
|
|
[None, "text/plain", "text/markdown", "text/csv", "application/json", "application/pdf", "image/png", "application/xml-dtd"],
|
|
)
|
|
def test_is_active_content_mime_type_keeps_passive_types_inline(mime_type: str | None) -> None:
|
|
assert not artifacts_router._is_active_content_mime_type(mime_type)
|
|
|
|
|
|
def test_get_artifact_xml_download_supports_bounded_range_requests(tmp_path, monkeypatch) -> None:
|
|
# The artifacts panel previews .xml as code through a Range fetch, so
|
|
# forcing the attachment disposition must keep the bounded preview.
|
|
payload = ('<?xml version="1.0"?><items>' + "<item>0123456789</item>" * 50_000 + "</items>").encode()
|
|
artifact_path = tmp_path / "large.xml"
|
|
artifact_path.write_bytes(payload)
|
|
monkeypatch.setattr(artifacts_router, "resolve_thread_virtual_path", lambda _thread_id, _path, user_id=None: artifact_path)
|
|
|
|
app = make_authed_test_app()
|
|
app.include_router(artifacts_router.router)
|
|
with TestClient(app) as client:
|
|
preview = client.get(
|
|
"/api/threads/thread-1/artifacts/mnt/user-data/outputs/large.xml",
|
|
headers={"Range": "bytes=0-1048575"},
|
|
)
|
|
|
|
assert preview.status_code == 206
|
|
assert preview.content == payload[:1_048_576]
|
|
assert preview.headers["content-range"] == f"bytes 0-1048575/{len(payload)}"
|
|
assert preview.headers["content-disposition"].startswith("attachment;")
|
|
assert preview.headers["x-content-type-options"] == "nosniff"
|
|
|
|
|
|
def test_get_artifact_download_false_does_not_force_attachment(tmp_path, monkeypatch) -> None:
|
|
artifact_path = tmp_path / "note.txt"
|
|
artifact_path.write_text("hello", encoding="utf-8")
|
|
|
|
monkeypatch.setattr(artifacts_router, "resolve_thread_virtual_path", lambda _thread_id, _path, user_id=None: artifact_path)
|
|
|
|
app = make_authed_test_app()
|
|
app.include_router(artifacts_router.router)
|
|
|
|
with TestClient(app) as client:
|
|
response = client.get("/api/threads/thread-1/artifacts/mnt/user-data/outputs/note.txt?download=false")
|
|
|
|
assert response.status_code == 200
|
|
assert response.text == "hello"
|
|
assert response.headers["content-disposition"].startswith("inline;")
|
|
assert response.headers["x-content-type-options"] == "nosniff"
|
|
|
|
|
|
def test_get_artifact_binary_preview_is_inline_file_response(tmp_path, monkeypatch) -> None:
|
|
# Binary (non-text, non-active-content) artifacts must go through the
|
|
# "inline_file" plan so get_artifact serves them via FileResponse.
|
|
artifact_path = tmp_path / "clip.mp3"
|
|
artifact_path.write_bytes(b"\x00\x01ID3fakeaudiobytes")
|
|
|
|
monkeypatch.setattr(artifacts_router, "resolve_thread_virtual_path", lambda _thread_id, _path, user_id=None: artifact_path)
|
|
|
|
response = asyncio.run(call_unwrapped(artifacts_router.get_artifact, "thread-1", "mnt/user-data/outputs/clip.mp3", _make_request()))
|
|
|
|
assert isinstance(response, FileResponse)
|
|
assert response.media_type == "audio/mpeg"
|
|
assert response.headers.get("content-disposition", "").startswith("inline;")
|
|
|
|
|
|
def test_get_artifact_binary_preview_supports_range_requests(tmp_path, monkeypatch) -> None:
|
|
# Regression test for #3240: dragging an audio/video artifact's seek bar
|
|
# reset playback to the start because the binary-preview branch used to
|
|
# buffer the whole file into a plain Response, which ignores byte-Range
|
|
# requests entirely (always 200 + full body, never 206). Browsers fall
|
|
# back to restarting playback from byte 0 when a seek's Range request
|
|
# doesn't come back as 206. FileResponse (used for the "file" plan
|
|
# already) handles Range/If-Range natively, so switching the binary
|
|
# branch to FileResponse fixes seeking for free.
|
|
# Cycle through all 256 byte values (incl. \x00) so is_text_file_by_content
|
|
# correctly sniffs this as binary, same as a real audio file would be -- an
|
|
# all-printable-ASCII payload would (correctly) be sniffed as text and miss
|
|
# the branch this test targets.
|
|
payload = bytes(i % 256 for i in range(1_000_000))
|
|
artifact_path = tmp_path / "clip.mp3"
|
|
artifact_path.write_bytes(payload)
|
|
|
|
monkeypatch.setattr(artifacts_router, "resolve_thread_virtual_path", lambda _thread_id, _path, user_id=None: artifact_path)
|
|
|
|
app = make_authed_test_app()
|
|
app.include_router(artifacts_router.router)
|
|
|
|
with TestClient(app) as client:
|
|
full = client.get("/api/threads/thread-1/artifacts/mnt/user-data/outputs/clip.mp3")
|
|
seek = client.get(
|
|
"/api/threads/thread-1/artifacts/mnt/user-data/outputs/clip.mp3",
|
|
headers={"Range": "bytes=500000-"},
|
|
)
|
|
|
|
assert full.status_code == 200
|
|
assert full.headers.get("accept-ranges") == "bytes"
|
|
assert full.content == payload
|
|
|
|
assert seek.status_code == 206
|
|
assert seek.headers.get("content-range") == f"bytes 500000-999999/{len(payload)}"
|
|
assert seek.content == payload[500000:]
|
|
assert seek.headers.get("content-disposition", "").startswith("inline;")
|
|
|
|
|
|
def test_get_artifact_download_true_forces_attachment_for_skill_archive(tmp_path, monkeypatch) -> None:
|
|
skill_path = tmp_path / "sample.skill"
|
|
with zipfile.ZipFile(skill_path, "w") as zip_ref:
|
|
zip_ref.writestr("notes.txt", "hello")
|
|
|
|
monkeypatch.setattr(artifacts_router, "resolve_thread_virtual_path", lambda _thread_id, _path, user_id=None: skill_path)
|
|
|
|
app = make_authed_test_app()
|
|
app.include_router(artifacts_router.router)
|
|
|
|
with TestClient(app) as client:
|
|
response = client.get("/api/threads/thread-1/artifacts/mnt/user-data/outputs/sample.skill/notes.txt?download=true")
|
|
|
|
assert response.status_code == 200
|
|
assert response.text == "hello"
|
|
assert response.headers.get("content-disposition", "").startswith("attachment;")
|
|
|
|
|
|
def _make_internal_request(owner: str | None, *, system_role: str = INTERNAL_SYSTEM_ROLE) -> Request:
|
|
"""A request as it arrives from a trusted internal caller.
|
|
|
|
``system_role`` is stamped onto ``request.state.user`` the way
|
|
``AuthMiddleware`` does after validating the internal token. When *owner*
|
|
is given it is carried in the owner-user-id header.
|
|
"""
|
|
headers: list[tuple[bytes, bytes]] = []
|
|
if owner is not None:
|
|
headers.append((INTERNAL_OWNER_USER_ID_HEADER_NAME.lower().encode(), owner.encode()))
|
|
request = Request({"type": "http", "method": "GET", "path": "/", "headers": headers, "query_string": b""})
|
|
request.state.user = SimpleNamespace(id="default", system_role=system_role)
|
|
return request
|
|
|
|
|
|
def _capture_resolved_user_id(monkeypatch, tmp_path) -> dict:
|
|
"""Patch resolve_thread_virtual_path to record the user_id it is called with."""
|
|
artifact_path = tmp_path / "index.html"
|
|
artifact_path.write_text("<html>", encoding="utf-8")
|
|
seen: dict = {}
|
|
|
|
def fake_resolve(_thread_id, _path, user_id=None):
|
|
seen["user_id"] = user_id
|
|
return artifact_path
|
|
|
|
monkeypatch.setattr(artifacts_router, "resolve_thread_virtual_path", fake_resolve)
|
|
return seen
|
|
|
|
|
|
def test_get_artifact_scopes_to_trusted_owner_header(tmp_path, monkeypatch) -> None:
|
|
# An internal caller acting for an owner must resolve the artifact under
|
|
# that owner's storage, not the synthetic internal user.
|
|
seen = _capture_resolved_user_id(monkeypatch, tmp_path)
|
|
request = _make_internal_request("owner-123")
|
|
|
|
asyncio.run(call_unwrapped(artifacts_router.get_artifact, "thread-1", "mnt/user-data/outputs/index.html", request))
|
|
|
|
assert seen["user_id"] == "owner-123"
|
|
|
|
|
|
def test_get_artifact_normalizes_raw_owner_id_from_trusted_header(tmp_path, monkeypatch) -> None:
|
|
# The trusted header carries the raw platform owner id (channel workers
|
|
# send it unsanitized; see ChannelManager._owner_headers), while run files
|
|
# live under the make_safe_user_id bucket — so a raw id with chars outside
|
|
# [A-Za-z0-9_-] must resolve to the normalized bucket, not the raw one.
|
|
seen = _capture_resolved_user_id(monkeypatch, tmp_path)
|
|
raw_owner = "ou_7d8a.6e6d@example:id"
|
|
request = _make_internal_request(raw_owner)
|
|
|
|
asyncio.run(call_unwrapped(artifacts_router.get_artifact, "thread-1", "mnt/user-data/outputs/index.html", request))
|
|
|
|
assert seen["user_id"] == make_safe_user_id(raw_owner)
|
|
assert seen["user_id"] != raw_owner
|
|
|
|
|
|
def test_get_artifact_without_owner_header_falls_back_to_effective_user(tmp_path, monkeypatch) -> None:
|
|
# No owner header → no override; resolution falls back to the effective user
|
|
# (user_id=None lets resolve_thread_virtual_path apply its default).
|
|
seen = _capture_resolved_user_id(monkeypatch, tmp_path)
|
|
request = _make_internal_request(None)
|
|
|
|
asyncio.run(call_unwrapped(artifacts_router.get_artifact, "thread-1", "mnt/user-data/outputs/index.html", request))
|
|
|
|
assert seen["user_id"] is None
|
|
|
|
|
|
def test_get_artifact_ignores_owner_header_for_non_internal_caller(tmp_path, monkeypatch) -> None:
|
|
# The owner header is only trusted for internal callers; a normal user
|
|
# carrying it must not be able to read another user's storage.
|
|
seen = _capture_resolved_user_id(monkeypatch, tmp_path)
|
|
request = _make_internal_request("owner-123", system_role="user")
|
|
|
|
asyncio.run(call_unwrapped(artifacts_router.get_artifact, "thread-1", "mnt/user-data/outputs/index.html", request))
|
|
|
|
assert seen["user_id"] is None
|
|
|
|
|
|
def test_skill_archive_preview_rejects_oversized_member_before_decompression(tmp_path) -> None:
|
|
skill_path = tmp_path / "sample.skill"
|
|
payload = b"A" * (artifacts_router.MAX_SKILL_ARCHIVE_MEMBER_BYTES + 1)
|
|
with zipfile.ZipFile(skill_path, "w", compression=zipfile.ZIP_DEFLATED, compresslevel=9) as zip_ref:
|
|
zip_ref.writestr("SKILL.md", payload)
|
|
|
|
assert skill_path.stat().st_size < artifacts_router.MAX_SKILL_ARCHIVE_MEMBER_BYTES
|
|
|
|
with pytest.raises(HTTPException) as exc_info:
|
|
artifacts_router._extract_file_from_skill_archive(skill_path, "SKILL.md")
|
|
|
|
assert exc_info.value.status_code == 413
|
|
|
|
|
|
def test_get_artifact_large_text_skips_etag(tmp_path, monkeypatch) -> None:
|
|
# A text artifact larger than MAX_EDITABLE_ARTIFACT_BYTES must not be hashed
|
|
# on every GET / Range request (performance P1 from review). The response
|
|
# still streams, but carries no full-content SHA-256 ETag; the client falls
|
|
# back to its own hashing where crypto.subtle is available.
|
|
payload = b"a" * (artifacts_router.MAX_EDITABLE_ARTIFACT_BYTES + 1)
|
|
artifact_path = tmp_path / "large.txt"
|
|
artifact_path.write_bytes(payload)
|
|
monkeypatch.setattr(
|
|
artifacts_router,
|
|
"resolve_thread_virtual_path",
|
|
lambda _thread_id, _path, user_id=None: artifact_path,
|
|
)
|
|
|
|
app = make_authed_test_app()
|
|
app.include_router(artifacts_router.router)
|
|
with TestClient(app) as client:
|
|
response = client.get(
|
|
"/api/threads/thread-1/artifacts/mnt/user-data/outputs/large.txt",
|
|
)
|
|
|
|
assert response.status_code == 200
|
|
# Oversized artifacts skip the full-file SHA-256 pass, so there must be no
|
|
# 64-hex content-hash ETag. Starlette's FileResponse may still attach a
|
|
# cheap mtime/size-derived ETag, which requires no file read.
|
|
etag = response.headers.get("etag")
|
|
assert etag is None or len(etag.strip('"')) != 64
|
|
assert response.content == payload
|
|
|
|
|
|
def test_get_artifact_large_active_content_skips_etag(tmp_path, monkeypatch) -> None:
|
|
# Active content (e.g. .html) is force-downloaded. A large active file must
|
|
# still force a download but skip the full-file SHA-256 pass (performance P1).
|
|
payload = "<html>" + ("a" * (artifacts_router.MAX_EDITABLE_ARTIFACT_BYTES + 1)) + "</html>"
|
|
artifact_path = tmp_path / "large.html"
|
|
artifact_path.write_text(payload, encoding="utf-8")
|
|
monkeypatch.setattr(
|
|
artifacts_router,
|
|
"resolve_thread_virtual_path",
|
|
lambda _thread_id, _path, user_id=None: artifact_path,
|
|
)
|
|
|
|
response = asyncio.run(
|
|
call_unwrapped(
|
|
artifacts_router.get_artifact,
|
|
"thread-1",
|
|
"mnt/user-data/outputs/large.html",
|
|
_make_request(),
|
|
)
|
|
)
|
|
|
|
assert isinstance(response, FileResponse)
|
|
assert response.headers.get("content-disposition", "").startswith("attachment;")
|
|
assert response.headers.get("etag") is None
|