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

579 lines
26 KiB
Python

import asyncio
import functools
import hashlib
import logging
import mimetypes
import os
import stat
import tempfile
import zipfile
from collections.abc import AsyncIterator
from contextlib import asynccontextmanager
from pathlib import Path
from urllib.parse import quote
from fastapi import APIRouter, HTTPException, Request
from fastapi.responses import FileResponse, Response
from pydantic import BaseModel, Field
from app.gateway.authz import SandboxRequestLease, require_permission, try_acquire_sandbox_for_request
from app.gateway.deps import get_run_manager
from app.gateway.internal_auth import get_trusted_internal_owner_user_id
from app.gateway.path_utils import normalize_outputs_virtual_path, resolve_outputs_confined_path, resolve_thread_virtual_path
from deerflow.authz.sandbox_authz import safe_app_config
from deerflow.config.paths import make_safe_user_id
from deerflow.runtime import ConflictError, ThreadOperationKind
from deerflow.runtime.user_context import get_effective_user_id
from deerflow.sandbox.sandbox_provider import get_sandbox_provider
from deerflow.utils.text_detection import _is_active_content_mime_type, is_text_file_by_content
from deerflow.utils.thread_id import ThreadId
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api", tags=["artifacts"])
# Active-content MIME classification (``_is_active_content_mime_type``) lives
# in ``deerflow.utils.text_detection``, shared with the project-document shelf.
MAX_SKILL_ARCHIVE_MEMBER_BYTES = 16 * 1024 * 1024
_SKILL_ARCHIVE_READ_CHUNK_SIZE = 64 * 1024
MAX_EDITABLE_ARTIFACT_BYTES = 2 * 1024 * 1024
_ARTIFACT_EDIT_TEMP_PREFIX = ".artifact-edit-"
class ArtifactUpdateRequest(BaseModel):
content: str
expected_sha256: str = Field(pattern=r"^[0-9a-f]{64}$")
class ArtifactUpdateResponse(BaseModel):
path: str
sha256: str
size: int
@asynccontextmanager
async def reserve_artifact_write(request: Request, thread_id: str, *, user_id: str) -> AsyncIterator[None]:
"""Serialize an artifact edit against runs and other thread mutations."""
run_manager = get_run_manager(request)
async with run_manager.reserve_thread_operation(
thread_id,
kind=ThreadOperationKind.artifact_write,
user_id=user_id,
):
yield
def _normalize_editable_artifact_path(path: str) -> str:
# The outputs-only rule is shared with channel attachment delivery:
# ``normalize_outputs_virtual_path`` collapses ``..`` before its prefix check
# and ``resolve_outputs_confined_path`` re-checks the resolved host path, so
# neither an encoded ``..`` nor a symlink planted in ``outputs/`` can
# redirect the edit to a sibling ``user-data/`` directory.
virtual_path = normalize_outputs_virtual_path(path)
if ".skill/" in virtual_path or virtual_path.endswith(".skill"):
raise HTTPException(status_code=415, detail="Skill archives cannot be edited in the artifacts panel")
return virtual_path
def _load_editable_artifact(actual_path: Path, path: str, expected_sha256: str) -> tuple[bytes, os.stat_result]:
try:
file_stat = os.lstat(actual_path)
except FileNotFoundError:
raise HTTPException(status_code=404, detail=f"Artifact not found: {path}") from None
if stat.S_ISLNK(file_stat.st_mode):
raise HTTPException(status_code=415, detail="Symlinked artifacts cannot be edited")
if not stat.S_ISREG(file_stat.st_mode):
raise HTTPException(status_code=400, detail=f"Path is not a file: {path}")
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()
if len(current) > MAX_EDITABLE_ARTIFACT_BYTES:
raise HTTPException(status_code=413, detail="Artifact is too large to edit")
if b"\x00" in current:
raise HTTPException(status_code=415, detail="Binary artifacts cannot be edited")
try:
current.decode("utf-8")
except UnicodeDecodeError:
raise HTTPException(status_code=415, detail="Only UTF-8 text artifacts can be edited") from None
current_sha256 = hashlib.sha256(current).hexdigest()
if current_sha256 != expected_sha256:
raise HTTPException(status_code=412, detail="Artifact changed since it was opened")
return current, file_stat
def _encode_artifact_update(content: str) -> bytes:
encoded = content.encode("utf-8")
if len(encoded) > MAX_EDITABLE_ARTIFACT_BYTES:
raise HTTPException(status_code=413, detail="Artifact is too large to edit")
if b"\x00" in encoded:
raise HTTPException(status_code=415, detail="Binary content cannot be saved as an artifact")
return encoded
def _replace_artifact_atomically(actual_path: Path, content: bytes, file_stat: os.stat_result) -> None:
temp_fd, temp_path_str = tempfile.mkstemp(prefix=_ARTIFACT_EDIT_TEMP_PREFIX, dir=actual_path.parent)
temp_path = Path(temp_path_str)
try:
# Preserve ownership where possible and keep replacement permissions
# scoped to the owner/group. The shared outputs directory allows a
# mounted sandbox to reach the file without making it world-writable.
if hasattr(os, "fchown"):
try:
os.fchown(temp_fd, file_stat.st_uid, file_stat.st_gid)
except OSError:
logger.debug("Could not preserve artifact ownership: %s", actual_path, exc_info=True)
# Windows has no fchmod and uses ACLs rather than POSIX mode bits.
# Keep the mkstemp permissions there; retain the existing POSIX
# behavior on platforms that expose descriptor-based chmod.
if hasattr(os, "fchmod"):
os.fchmod(temp_fd, stat.S_IMODE(file_stat.st_mode) | 0o660)
with os.fdopen(temp_fd, "wb") as handle:
temp_fd = -1
handle.write(content)
handle.flush()
os.fsync(handle.fileno())
os.replace(temp_path, actual_path)
# Invalidate the SHA-256 cache after a successful edit so the next
# preview request computes the new digest. Edits are rare, so
# clearing the whole 256-entry LRU costs nothing (see PR review).
_sha256_of_file_cached.cache_clear()
finally:
if temp_fd >= 0:
os.close(temp_fd)
try:
temp_path.unlink()
except FileNotFoundError:
pass
def _sync_artifact_to_sandbox(sandbox, virtual_path: str, content: bytes) -> None:
sandbox.update_file(virtual_path, content)
def _build_content_disposition(disposition_type: str, filename: str) -> str:
"""Build an RFC 5987 encoded Content-Disposition header value."""
return f"{disposition_type}; filename*=UTF-8''{quote(filename)}"
def _build_attachment_headers(filename: str, extra_headers: dict[str, str] | None = None) -> dict[str, str]:
# nosniff: a declared binary/document type must never be reinterpreted as
# HTML — the transport-level guarantee behind unsandboxed PDF preview.
headers = {"Content-Disposition": _build_content_disposition("attachment", filename), "X-Content-Type-Options": "nosniff"}
if extra_headers:
headers.update(extra_headers)
return headers
def _slice_byte_range(content: bytes, range_header: str | None) -> tuple[bytes, int, dict[str, str]]:
"""Apply one RFC 9110 byte range to an in-memory archive member."""
size = len(content)
headers = {"Accept-Ranges": "bytes"}
if range_header is None:
return content, 200, headers
def unsatisfied() -> HTTPException:
return HTTPException(
status_code=416,
detail="Requested range is not satisfiable",
headers={"Accept-Ranges": "bytes", "Content-Range": f"bytes */{size}"},
)
if not range_header.startswith("bytes=") or "," in range_header:
raise unsatisfied()
range_spec = range_header.removeprefix("bytes=")
if "-" not in range_spec:
raise unsatisfied()
start_text, end_text = range_spec.split("-", 1)
try:
if start_text:
start = int(start_text)
end = size - 1 if not end_text else min(int(end_text), size - 1)
else:
suffix_length = int(end_text)
if suffix_length <= 0:
raise unsatisfied()
start = max(size - suffix_length, 0)
end = size - 1
except ValueError as exc:
raise unsatisfied() from exc
if size == 0 or start < 0 or start >= size or end < start:
raise unsatisfied()
ranged_content = content[start : end + 1]
headers.update(
{
"Content-Range": f"bytes {start}-{end}/{size}",
"Content-Length": str(len(ranged_content)),
}
)
return ranged_content, 206, headers
def _read_skill_archive_member(zip_ref: zipfile.ZipFile, info: zipfile.ZipInfo) -> bytes:
"""Read a .skill archive member while enforcing an uncompressed size cap."""
if info.file_size > MAX_SKILL_ARCHIVE_MEMBER_BYTES:
raise HTTPException(status_code=413, detail="Skill archive member is too large to preview")
chunks: list[bytes] = []
total_read = 0
with zip_ref.open(info, "r") as src:
while chunk := src.read(_SKILL_ARCHIVE_READ_CHUNK_SIZE):
total_read += len(chunk)
if total_read > MAX_SKILL_ARCHIVE_MEMBER_BYTES:
raise HTTPException(status_code=413, detail="Skill archive member is too large to preview")
chunks.append(chunk)
return b"".join(chunks)
def _extract_file_from_skill_archive(zip_path: Path, internal_path: str) -> bytes | None:
"""Extract a file from a .skill ZIP archive.
Args:
zip_path: Path to the .skill file (ZIP archive).
internal_path: Path to the file inside the archive (e.g., "SKILL.md").
Returns:
The file content as bytes, or None if not found.
"""
if not zipfile.is_zipfile(zip_path):
return None
try:
with zipfile.ZipFile(zip_path, "r") as zip_ref:
# List all files in the archive
infos_by_name = {info.filename: info for info in zip_ref.infolist()}
# Try direct path first
if internal_path in infos_by_name:
return _read_skill_archive_member(zip_ref, infos_by_name[internal_path])
# Try with any top-level directory prefix (e.g., "skill-name/SKILL.md")
for name, info in infos_by_name.items():
if name.endswith("/" + internal_path) or name == internal_path:
return _read_skill_archive_member(zip_ref, info)
# Not found
return None
except (zipfile.BadZipFile, KeyError):
return None
def _load_skill_archive_member(actual_skill_path: Path, skill_file_path: str, internal_path: str) -> tuple[bytes, str | None]:
"""Worker-thread body for the ``.skill`` branch of ``get_artifact``.
The ``exists`` / ``is_file`` probes, the ZIP open+extract, and the MIME
sniff (``mimetypes`` lazily stats the system MIME database on first use) are
blocking filesystem IO and must stay off the event loop. Raised
``HTTPException``s propagate through ``asyncio.to_thread`` unchanged,
preserving status codes.
"""
if not actual_skill_path.exists():
raise HTTPException(status_code=404, detail=f"Skill file not found: {skill_file_path}")
if not actual_skill_path.is_file():
raise HTTPException(status_code=400, detail=f"Path is not a file: {skill_file_path}")
content = _extract_file_from_skill_archive(actual_skill_path, internal_path)
if content is None:
raise HTTPException(status_code=404, detail=f"File '{internal_path}' not found in skill archive")
mime_type, _ = mimetypes.guess_type(internal_path)
return content, mime_type
def _read_artifact_payload(actual_path: Path, path: str, download: bool) -> tuple[str, str | None]:
"""Worker-thread body for the regular branch of ``get_artifact``.
Stat probes and MIME sniffing (``mimetypes`` lazily stats the system MIME
database on first use) are blocking filesystem IO. Returns a
``(kind, mime_type)`` plan the handler turns into a streamed
``FileResponse``. Inline text and binary previews both use FileResponse so
clients can request a bounded byte range instead of buffering a whole file.
"""
if not actual_path.exists():
raise HTTPException(status_code=404, detail=f"Artifact not found: {path}")
if not actual_path.is_file():
raise HTTPException(status_code=400, detail=f"Path is not a file: {path}")
mime_type, _ = mimetypes.guess_type(actual_path)
# Active content / explicit download is streamed by FileResponse — no read here.
if download or _is_active_content_mime_type(mime_type):
return ("file", mime_type)
if mime_type and mime_type.startswith("text/"):
return ("inline_file", mime_type)
if is_text_file_by_content(actual_path):
return ("inline_file", mime_type or "text/plain")
return ("inline_file", mime_type)
def _sha256_of_file(path: Path) -> str:
"""Return the hex SHA-256 digest of *path* without loading it whole.
Computing the digest on the Gateway lets the browser skip its own
crypto.subtle-based hashing, which is unavailable in non-secure contexts
(e.g. http://<lan-ip>:<port>) and otherwise breaks artifact preview +
inline editing (see issue #4864).
The digest is cached by (path, mtime_ns, size) so the many small ``Range``
requests a browser issues while scrubbing/paginating a preview do not each
re-hash a potentially huge artifact from scratch (raised in PR review).
"""
stat = path.stat()
return _sha256_of_file_cached(str(path), stat.st_mtime_ns, stat.st_size)
@functools.lru_cache(maxsize=256)
def _sha256_of_file_cached(path: str, mtime_ns: int, size: int) -> str:
"""Cached SHA-256 of *path*; the size/mtime args invalidate stale entries."""
digest = hashlib.sha256()
with open(path, "rb") as handle:
for chunk in iter(lambda: handle.read(1 << 20), b""):
digest.update(chunk)
return digest.hexdigest()
@router.get(
"/threads/{thread_id}/artifacts/{path:path}",
summary="Get Artifact File",
description="Retrieve an artifact file generated by the AI agent. Text and binary files can be viewed inline, while active web content is always downloaded.",
)
@require_permission("threads", "read", owner_check=True)
async def get_artifact(thread_id: ThreadId, path: str, request: Request, download: bool = False) -> Response:
"""Get an artifact file by its path.
The endpoint automatically detects file types and returns appropriate content types.
Use the `download` query parameter to force file download for non-active content.
Args:
thread_id: The thread ID.
path: The artifact path with virtual prefix (e.g., mnt/user-data/outputs/file.txt).
request: FastAPI request object (automatically injected).
Returns:
The file content as a FileResponse with appropriate content type:
- Active content (HTML and XML documents, including XHTML/SVG): Served as download attachment
- Text files: Plain text with proper MIME type
- Binary files: Inline display with download option
Raises:
HTTPException:
- 400 if path is invalid or not a file
- 403 if access denied (path traversal detected)
- 404 if file not found
Query Parameters:
download (bool): If true, forces attachment download for file types that are
otherwise returned inline or as plain text. Active HTML/XML content
(including XHTML and SVG) is always downloaded regardless of this flag.
Example:
- Get text file inline: `/api/threads/abc123/artifacts/mnt/user-data/outputs/notes.txt`
- Download file: `/api/threads/abc123/artifacts/mnt/user-data/outputs/data.csv?download=true`
- Active web content such as `.html`, `.xhtml`, `.svg`, and `.xml` artifacts is always downloaded
"""
# Trusted internal callers may act on behalf of a thread's owner via the
# owner-user-id header (honored only after the internal token validates).
# The header carries the raw platform owner id, while runs store files
# under the make_safe_user_id bucket (the same normalization the channel
# file pipeline and the memory router apply), so resolution uses the
# normalized id. Browser/API callers get None here and fall back to the
# effective user.
raw_owner_user_id = get_trusted_internal_owner_user_id(request)
owner_user_id = make_safe_user_id(raw_owner_user_id) if raw_owner_user_id else None
# Check if this is a request for a file inside a .skill archive (e.g., xxx.skill/SKILL.md)
if ".skill/" in path:
# Split the path at ".skill/" to get the ZIP file path and internal path
skill_marker = ".skill/"
marker_pos = path.find(skill_marker)
skill_file_path = path[: marker_pos + len(".skill")] # e.g., "mnt/user-data/outputs/my-skill.skill"
internal_path = path[marker_pos + len(skill_marker) :] # e.g., "SKILL.md"
actual_skill_path = await asyncio.to_thread(resolve_thread_virtual_path, thread_id, skill_file_path, user_id=owner_user_id)
# Offload the stat probes + ZIP open/extract + MIME sniff (blocking filesystem IO).
content, mime_type = await asyncio.to_thread(_load_skill_archive_member, actual_skill_path, skill_file_path, internal_path)
# Add cache headers to avoid repeated ZIP extraction (cache for 5 minutes)
cache_headers = {"Cache-Control": "private, max-age=300"}
download_name = Path(internal_path).name or actual_skill_path.stem
if download or _is_active_content_mime_type(mime_type):
return Response(content=content, media_type=mime_type or "application/octet-stream", headers=_build_attachment_headers(download_name, cache_headers))
# Archive members are already bounded during extraction. Preserve byte
# semantics here so the frontend can request only its preview budget,
# including a final partial UTF-8 sequence.
request_headers = request.headers if request is not None else {}
range_header = None if request_headers.get("if-range") else request_headers.get("range")
ranged_content, status_code, range_headers = _slice_byte_range(content, range_header)
inline_headers = {
**cache_headers,
**range_headers,
"X-Content-Type-Options": "nosniff",
# Real SHA-256 so the browser can skip crypto.subtle (unavailable on
# non-secure contexts) when previewing / editing artifacts (#4864).
"ETag": f'"{hashlib.sha256(content).hexdigest()}"',
}
if mime_type and mime_type.startswith("text/"):
return Response(content=ranged_content, status_code=status_code, media_type=mime_type, headers=inline_headers)
# Default to plain text for unknown types that look like text
try:
content.decode("utf-8")
return Response(content=ranged_content, status_code=status_code, media_type="text/plain", headers=inline_headers)
except UnicodeDecodeError:
return Response(
content=ranged_content,
status_code=status_code,
media_type=mime_type or "application/octet-stream",
headers=inline_headers,
)
actual_path = await asyncio.to_thread(resolve_thread_virtual_path, thread_id, path, user_id=owner_user_id)
logger.info(f"Resolving artifact path: thread_id={thread_id}, requested_path={path}, actual_path={actual_path}")
# Offload path stat + MIME sniff (blocking filesystem IO). Every regular
# artifact response is streamed by FileResponse; the worker only reports
# disposition and media type.
kind, mime_type = await asyncio.to_thread(_read_artifact_payload, actual_path, path, download)
if kind == "file":
# Always force download for active content types to prevent script
# execution in the application origin when users open generated artifacts.
headers = {**_build_attachment_headers(actual_path.name)}
file_size = await asyncio.to_thread(lambda: actual_path.stat().st_size)
if file_size <= MAX_EDITABLE_ARTIFACT_BYTES:
# Real SHA-256 so the browser can skip crypto.subtle (unavailable
# on non-secure contexts) when previewing / editing artifacts (#4864).
# Skipped for oversized artifacts to avoid a full-file read on every
# GET / Range request (raised in review as a performance P1).
content_sha256 = await asyncio.to_thread(_sha256_of_file, actual_path)
headers["ETag"] = f'"{content_sha256}"'
return FileResponse(
path=actual_path,
filename=actual_path.name,
media_type=mime_type,
headers=headers,
)
if kind == "inline_file":
# FileResponse honors byte-Range requests for large text previews and
headers = {"Content-Disposition": _build_content_disposition("inline", actual_path.name), "X-Content-Type-Options": "nosniff"}
file_size = await asyncio.to_thread(lambda: actual_path.stat().st_size)
if file_size <= MAX_EDITABLE_ARTIFACT_BYTES:
# Real SHA-256 so the browser can skip crypto.subtle (unavailable
# on non-secure contexts) when previewing / editing artifacts (#4864).
# Skipped for oversized artifacts to avoid a full-file read on every
# GET / Range request (raised in review as a performance P1).
content_sha256 = await asyncio.to_thread(_sha256_of_file, actual_path)
headers["ETag"] = f'"{content_sha256}"'
return FileResponse(
path=actual_path,
media_type=mime_type,
headers=headers,
)
raise AssertionError(f"Unhandled artifact response kind: {kind!r}")
@router.put(
"/threads/{thread_id}/artifacts/{path:path}",
response_model=ArtifactUpdateResponse,
summary="Update Artifact File",
description="Replace an existing UTF-8 text artifact after verifying that its content has not changed.",
)
@require_permission("threads", "write", owner_check=True, require_existing=True)
async def update_artifact(
thread_id: ThreadId,
path: str,
body: ArtifactUpdateRequest,
request: Request,
) -> ArtifactUpdateResponse:
"""Update an existing text artifact while the thread has no active run.
The host-side artifact file is updated first; when the sandbox provider is
not thread-mounted, the new content is also synced into the thread's
sandbox. Under ``authorization.enabled``, a caller denied
``sandbox:execute`` skips that sandbox sync (the host-side update still
completes).
"""
virtual_path = _normalize_editable_artifact_path(path)
raw_owner_user_id = get_trusted_internal_owner_user_id(request)
effective_user_id = make_safe_user_id(raw_owner_user_id) if raw_owner_user_id else get_effective_user_id()
sandbox_lease: SandboxRequestLease | None = None
sandbox = None
try:
async with reserve_artifact_write(request, thread_id, user_id=effective_user_id):
actual_path = await asyncio.to_thread(
resolve_outputs_confined_path,
thread_id,
virtual_path,
user_id=effective_user_id,
)
current, file_stat = await asyncio.to_thread(
_load_editable_artifact,
actual_path,
virtual_path,
body.expected_sha256,
)
updated = _encode_artifact_update(body.content)
sandbox_provider = get_sandbox_provider()
if not bool(getattr(sandbox_provider, "uses_thread_data_mounts", False)):
# Phase 3: enforce sandbox:execute before acquiring — a denied
# role skips the sandbox sync; the host-side artifact update
# still completes (the agent cannot consume the sandbox copy
# anyway when sandbox execution is denied).
sandbox_lease = await try_acquire_sandbox_for_request(
request,
sandbox_provider,
thread_id,
user_id=effective_user_id,
app_config=safe_app_config(),
owner_prefix="gateway:artifact",
)
sandbox = sandbox_lease.sandbox
if not sandbox_lease.denied and sandbox is None:
raise RuntimeError("Failed to acquire sandbox for artifact update")
try:
if sandbox is not None:
await asyncio.to_thread(_sync_artifact_to_sandbox, sandbox, virtual_path, updated)
await asyncio.to_thread(_replace_artifact_atomically, actual_path, updated, file_stat)
# Invalidate any cached digest for this path so a subsequent GET
# serves the fresh SHA-256. The (path, mtime_ns, size) LRU key can
# collide on a same-size, sub-nanosecond re-write (review nit).
_sha256_of_file_cached.cache_clear()
except Exception:
if sandbox is not None:
try:
await asyncio.to_thread(_sync_artifact_to_sandbox, sandbox, virtual_path, current)
except Exception:
logger.exception("Failed to roll back remote artifact after artifact update failure: %s", virtual_path)
raise
except ConflictError:
raise HTTPException(status_code=409, detail="Thread has a run in flight. Save after the run finishes.") from None
except HTTPException:
raise
except Exception:
logger.exception("Failed to update artifact %s for thread %s", path, thread_id)
raise HTTPException(status_code=500, detail="Failed to update artifact") from None
finally:
if sandbox_lease is not None:
try:
await sandbox_lease.release()
except Exception:
logger.warning(
"Failed to release sandbox request lease after artifact update: %s",
sandbox_lease.sandbox_id,
exc_info=True,
)
content_sha256 = hashlib.sha256(updated).hexdigest()
return ArtifactUpdateResponse(
path=virtual_path,
sha256=content_sha256,
size=len(updated),
)