deer-flow/backend/tests/test_artifacts_router.py
Zeren Wang a58ab484a6
feat(projects): Projects MVP Phase 2 — instructions, document shelf, promotion, trash (#5443)
* 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.
2026-09-16 18:46:18 +08:00

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