Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 5b30a73142 | |||
| c031432b0c | |||
| cd65d98a3b | |||
| 6fac046dd4 |
@@ -0,0 +1,73 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { ATTACHMENT_SIZE_LIMITS_MB, classifyAttachment, validateAttachments } from "./attachments";
|
||||
|
||||
function makeFile(name: string, type: string, bytes = 10): File {
|
||||
return new File([new Uint8Array(bytes)], name, { type });
|
||||
}
|
||||
|
||||
const MB = 1024 * 1024;
|
||||
|
||||
describe("classifyAttachment", () => {
|
||||
it("classifies images by MIME", () => {
|
||||
expect(classifyAttachment(makeFile("a.png", "image/png"))).toBe("image");
|
||||
expect(classifyAttachment(makeFile("a.jpg", "image/jpeg"))).toBe("image");
|
||||
});
|
||||
|
||||
it("classifies PDF by MIME or extension", () => {
|
||||
expect(classifyAttachment(makeFile("a.pdf", "application/pdf"))).toBe("pdf");
|
||||
// Some browsers report an empty type for PDFs — fall back to extension.
|
||||
expect(classifyAttachment(makeFile("a.pdf", ""))).toBe("pdf");
|
||||
});
|
||||
|
||||
it("classifies text/code, including code files with empty/wrong MIME", () => {
|
||||
expect(classifyAttachment(makeFile("a.txt", "text/plain"))).toBe("text");
|
||||
expect(classifyAttachment(makeFile("a.json", "application/json"))).toBe("text");
|
||||
// .ts reports video/mp2t in some browsers; extension wins.
|
||||
expect(classifyAttachment(makeFile("a.ts", "video/mp2t"))).toBe("text");
|
||||
expect(classifyAttachment(makeFile("main.rs", ""))).toBe("text");
|
||||
expect(classifyAttachment(makeFile("notebook.ipynb", ""))).toBe("text");
|
||||
// Windows/Excel tags .csv as application/vnd.ms-excel — extension wins.
|
||||
expect(classifyAttachment(makeFile("data.csv", "application/vnd.ms-excel"))).toBe("text");
|
||||
});
|
||||
|
||||
it("rejects office/binary types", () => {
|
||||
const pptx = "application/vnd.openxmlformats-officedocument.presentationml.presentation";
|
||||
expect(classifyAttachment(makeFile("deck.pptx", pptx))).toBeNull();
|
||||
expect(classifyAttachment(makeFile("a.zip", "application/zip"))).toBeNull();
|
||||
expect(classifyAttachment(makeFile("a.bin", "application/octet-stream"))).toBeNull();
|
||||
expect(classifyAttachment(makeFile("a.mp4", "video/mp4"))).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("validateAttachments", () => {
|
||||
it("accepts supported files within their size limit", () => {
|
||||
const files = [makeFile("a.png", "image/png"), makeFile("a.pdf", "application/pdf")];
|
||||
const { accepted, errors } = validateAttachments(files);
|
||||
expect(accepted).toHaveLength(2);
|
||||
expect(errors).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("rejects unsupported types with a message", () => {
|
||||
const pptx = "application/vnd.openxmlformats-officedocument.presentationml.presentation";
|
||||
const { accepted, errors } = validateAttachments([makeFile("deck.pptx", pptx)]);
|
||||
expect(accepted).toHaveLength(0);
|
||||
expect(errors).toHaveLength(1);
|
||||
expect(errors[0]).toContain("deck.pptx");
|
||||
});
|
||||
|
||||
it("rejects files over their per-type size limit", () => {
|
||||
const bigImage = makeFile("huge.png", "image/png", ATTACHMENT_SIZE_LIMITS_MB.image * MB + 1);
|
||||
const { accepted, errors } = validateAttachments([bigImage]);
|
||||
expect(accepted).toHaveLength(0);
|
||||
expect(errors[0]).toContain("too large");
|
||||
});
|
||||
|
||||
it("partitions a mixed batch into accepted + errors", () => {
|
||||
const ok = makeFile("a.png", "image/png");
|
||||
const badType = makeFile("a.zip", "application/zip");
|
||||
const tooBig = makeFile("big.pdf", "application/pdf", ATTACHMENT_SIZE_LIMITS_MB.pdf * MB + 1);
|
||||
const { accepted, errors } = validateAttachments([ok, badType, tooBig]);
|
||||
expect(accepted).toEqual([ok]);
|
||||
expect(errors).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,165 @@
|
||||
/**
|
||||
* Client-side attachment validation: which files can be attached, and how
|
||||
* large each type may be.
|
||||
*
|
||||
* This mirrors the authoritative server-side checks in
|
||||
* omnigent/runtime/content_resolver.py (`attachment_upload_limit`) and the
|
||||
* upload route (415 for unsupported types, 413 for oversized). Keeping a
|
||||
* copy here lets us reject a bad file at paste/drop/pick time — before a
|
||||
* slow upload — with a friendly message. The server still enforces; this is
|
||||
* UX only. Keep the limits in sync with the Python constants.
|
||||
*/
|
||||
|
||||
/** Per-type upload size limits, in megabytes. Mirrors the server caps. */
|
||||
export const ATTACHMENT_SIZE_LIMITS_MB = {
|
||||
image: 5,
|
||||
pdf: 20,
|
||||
text: 10,
|
||||
} as const;
|
||||
|
||||
export type AttachmentCategory = keyof typeof ATTACHMENT_SIZE_LIMITS_MB;
|
||||
|
||||
// Text-bearing application/* MIME types (the rest of the text-like surface
|
||||
// is text/*). Mirrors _TEXT_LIKE_APPLICATION_MIMES on the server.
|
||||
const TEXT_LIKE_APPLICATION_MIMES = new Set([
|
||||
"application/json",
|
||||
"application/javascript",
|
||||
"application/jsonl",
|
||||
"application/x-ndjson",
|
||||
"application/x-ipynb+json",
|
||||
]);
|
||||
|
||||
// Text/code extensions whose browser-reported MIME type is often empty or
|
||||
// wrong (e.g. a .ts file reports video/mp2t, .rs reports nothing). Mirrors
|
||||
// the code entries in _EXTRA_MIME_TYPES on the server so we accept the same
|
||||
// files the backend resolves to a text/* type.
|
||||
const TEXT_CODE_EXTENSIONS = new Set([
|
||||
".txt",
|
||||
".log",
|
||||
".md",
|
||||
".markdown",
|
||||
".csv",
|
||||
".json",
|
||||
".jsonl",
|
||||
".ndjson",
|
||||
".yaml",
|
||||
".yml",
|
||||
".toml",
|
||||
".ini",
|
||||
".cfg",
|
||||
".env",
|
||||
".lock",
|
||||
".proto",
|
||||
".graphql",
|
||||
".gql",
|
||||
".html",
|
||||
".htm",
|
||||
".xml",
|
||||
".css",
|
||||
".js",
|
||||
".jsx",
|
||||
".mjs",
|
||||
".cjs",
|
||||
".ts",
|
||||
".tsx",
|
||||
".py",
|
||||
".rb",
|
||||
".go",
|
||||
".rs",
|
||||
".java",
|
||||
".kt",
|
||||
".scala",
|
||||
".swift",
|
||||
".c",
|
||||
".h",
|
||||
".cc",
|
||||
".cpp",
|
||||
".hpp",
|
||||
".cs",
|
||||
".php",
|
||||
".pl",
|
||||
".r",
|
||||
".jl",
|
||||
".lua",
|
||||
".ex",
|
||||
".exs",
|
||||
".erl",
|
||||
".hs",
|
||||
".clj",
|
||||
".dart",
|
||||
".vue",
|
||||
".svelte",
|
||||
".sh",
|
||||
".bash",
|
||||
".zsh",
|
||||
".fish",
|
||||
".sql",
|
||||
".tf",
|
||||
".hcl",
|
||||
".gradle",
|
||||
".dockerfile",
|
||||
".ipynb",
|
||||
]);
|
||||
|
||||
function extensionOf(filename: string): string {
|
||||
const dot = filename.lastIndexOf(".");
|
||||
return dot >= 0 ? filename.slice(dot).toLowerCase() : "";
|
||||
}
|
||||
|
||||
/**
|
||||
* Classify a file into an attachment category, or `null` if its type is not
|
||||
* supported (e.g. pptx, docx, xlsx, zip, binaries). Uses the browser MIME
|
||||
* type first, falling back to the filename extension for code/text files
|
||||
* whose MIME is unreliable.
|
||||
*/
|
||||
export function classifyAttachment(file: File): AttachmentCategory | null {
|
||||
const type = file.type || "";
|
||||
const ext = extensionOf(file.name || "");
|
||||
|
||||
if (type.startsWith("image/")) return "image";
|
||||
if (type === "application/pdf" || ext === ".pdf") return "pdf";
|
||||
if (
|
||||
type.startsWith("text/") ||
|
||||
TEXT_LIKE_APPLICATION_MIMES.has(type) ||
|
||||
TEXT_CODE_EXTENSIONS.has(ext)
|
||||
) {
|
||||
return "text";
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export interface AttachmentValidation {
|
||||
/** Files that passed type + size checks. */
|
||||
accepted: File[];
|
||||
/** Human-readable rejection messages, one per rejected file. */
|
||||
errors: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Split *files* into accepted attachments and rejection messages. A file is
|
||||
* rejected when its type is unsupported, or when it exceeds the per-type
|
||||
* size limit.
|
||||
*/
|
||||
export function validateAttachments(files: File[]): AttachmentValidation {
|
||||
const accepted: File[] = [];
|
||||
const errors: string[] = [];
|
||||
|
||||
for (const file of files) {
|
||||
const name = file.name || "file";
|
||||
const category = classifyAttachment(file);
|
||||
if (category === null) {
|
||||
errors.push(
|
||||
`"${name}" can't be attached — only images, PDF, and text/code files are supported.`,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
const limitMb = ATTACHMENT_SIZE_LIMITS_MB[category];
|
||||
if (file.size > limitMb * 1024 * 1024) {
|
||||
errors.push(`"${name}" is too large — the limit for ${category} files is ${limitMb} MB.`);
|
||||
continue;
|
||||
}
|
||||
accepted.push(file);
|
||||
}
|
||||
|
||||
return { accepted, errors };
|
||||
}
|
||||
@@ -58,6 +58,7 @@ import { parseSystemMessage } from "@/lib/systemMessage";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { OttoIcon } from "@/components/icons/OttoIcon";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { validateAttachments } from "@/lib/attachments";
|
||||
import { useSurfaceFrontmost } from "@/hooks/useNativeServerSwitcher";
|
||||
import {
|
||||
isIOSShell,
|
||||
@@ -3090,6 +3091,7 @@ export function Composer({
|
||||
}: ComposerProps) {
|
||||
const [value, setValue] = useState("");
|
||||
const [files, setFiles] = useState<File[]>([]);
|
||||
const [attachmentError, setAttachmentError] = useState<string | null>(null);
|
||||
const [commandError, setCommandError] = useState<string | null>(null);
|
||||
const [planModeBusy, setPlanModeBusy] = useState(false);
|
||||
// Index of the highlighted item in the slash-command suggestions menu.
|
||||
@@ -3406,8 +3408,15 @@ export function Composer({
|
||||
const [isDragActive, setIsDragActive] = useState(false);
|
||||
|
||||
const addFiles = (incoming: File[]) => {
|
||||
setFiles((prev) => [...prev, ...incoming]);
|
||||
dirtyRef.current = true;
|
||||
// Reject unsupported types (only images, PDF, and text/code) and
|
||||
// oversized files up front — before the upload — with a friendly
|
||||
// message. The server enforces the same limits authoritatively.
|
||||
const { accepted, errors } = validateAttachments(incoming);
|
||||
if (accepted.length > 0) {
|
||||
setFiles((prev) => [...prev, ...accepted]);
|
||||
dirtyRef.current = true;
|
||||
}
|
||||
setAttachmentError(errors.length > 0 ? errors.join("\n") : null);
|
||||
};
|
||||
|
||||
const handleDrop = (e: DragEvent<HTMLDivElement>) => {
|
||||
@@ -3440,6 +3449,7 @@ export function Composer({
|
||||
|
||||
const removeFile = (index: number) => {
|
||||
setFiles((prev) => prev.filter((_, i) => i !== index));
|
||||
setAttachmentError(null);
|
||||
dirtyRef.current = true;
|
||||
};
|
||||
|
||||
@@ -3522,6 +3532,7 @@ export function Composer({
|
||||
dirtyRef.current = true;
|
||||
setValue("");
|
||||
setFiles([]);
|
||||
setAttachmentError(null);
|
||||
onClearAllQuotes();
|
||||
};
|
||||
|
||||
@@ -3833,6 +3844,12 @@ export function Composer({
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{/* Rejected-attachment feedback: unsupported type or too large */}
|
||||
{attachmentError !== null && (
|
||||
<div className="px-4 pb-2 text-xs text-destructive whitespace-pre-wrap">
|
||||
{attachmentError}
|
||||
</div>
|
||||
)}
|
||||
{/* Inline slash-command feedback: errors and /help output */}
|
||||
{commandError !== null && (
|
||||
<div className="px-4 pb-2 text-xs text-muted-foreground whitespace-pre-wrap">
|
||||
|
||||
@@ -91,6 +91,171 @@ _EXTRA_MIME_TYPES: dict[str, str] = {
|
||||
}
|
||||
|
||||
|
||||
# ── Attachment upload limits ──────────────────────────────────────────
|
||||
# Uploaded attachments are inlined into the model context as base64 (see
|
||||
# :func:`resolve_content_references`) and re-sent every turn, so sizes are
|
||||
# bounded well under the model's context budget and the provider's API
|
||||
# limits — Anthropic accepts images up to ~5 MB, PDFs up to ~32 MB / 100
|
||||
# pages, and ~32 MB per request total. The per-type caps below keep a
|
||||
# single attachment usable across a multi-turn conversation; the global
|
||||
# ceiling backstops the total request size after base64 inflation (~1.33x).
|
||||
# Mirrored client-side in ap-web/src/lib/attachments.ts — keep in sync.
|
||||
MAX_IMAGE_UPLOAD_BYTES: int = 5 * 1024 * 1024
|
||||
MAX_PDF_UPLOAD_BYTES: int = 20 * 1024 * 1024
|
||||
MAX_TEXT_UPLOAD_BYTES: int = 10 * 1024 * 1024
|
||||
MAX_ATTACHMENT_UPLOAD_BYTES: int = 25 * 1024 * 1024
|
||||
|
||||
# ``application/*`` MIME types we treat as text-like. The rest of the
|
||||
# text-like surface is ``text/*`` (covered by the prefix check) — these
|
||||
# are the text-bearing ``application/*`` types code/data files resolve to.
|
||||
_TEXT_LIKE_APPLICATION_MIMES: frozenset[str] = frozenset(
|
||||
{
|
||||
"application/json",
|
||||
"application/javascript",
|
||||
"application/jsonl",
|
||||
"application/x-ndjson",
|
||||
"application/x-ipynb+json",
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def attachment_upload_limit(content_type: str) -> int | None:
|
||||
"""
|
||||
Max upload size (bytes) for *content_type*, or ``None`` if the type is
|
||||
not an allowed attachment.
|
||||
|
||||
Allowed: images, PDF, and text-like files (``text/*`` plus a few
|
||||
text-bearing ``application/*`` types — JSON, JS, JSONL, notebooks).
|
||||
Office / binary formats (pptx, docx, xlsx, zip, …) return ``None`` and
|
||||
are rejected at upload: the model can't read their raw bytes
|
||||
(Anthropic's base64 ``document`` source accepts only PDF), so inlining
|
||||
them only produces garbled UTF-8 or — for large files — an oversized,
|
||||
context-blowing request. Callers reject ``None`` with HTTP 415.
|
||||
|
||||
:param content_type: The resolved MIME type, e.g. ``"image/png"``.
|
||||
Use :func:`_resolve_content_type` to derive it from the upload's
|
||||
declared type + filename first.
|
||||
:returns: The per-type byte limit (still subject to
|
||||
:data:`MAX_ATTACHMENT_UPLOAD_BYTES`), or ``None`` when the type is
|
||||
not an allowed attachment.
|
||||
"""
|
||||
if content_type.startswith("image/"):
|
||||
return MAX_IMAGE_UPLOAD_BYTES
|
||||
if content_type == "application/pdf":
|
||||
return MAX_PDF_UPLOAD_BYTES
|
||||
if content_type.startswith("text/") or content_type in _TEXT_LIKE_APPLICATION_MIMES:
|
||||
return MAX_TEXT_UPLOAD_BYTES
|
||||
return None
|
||||
|
||||
|
||||
# Extensions accepted as text/code attachments even when the upload's
|
||||
# declared MIME mislabels them as binary — e.g. a ``.csv`` tagged
|
||||
# ``application/vnd.ms-excel`` on Windows, or a ``.ts`` tagged
|
||||
# ``video/mp2t``. Mirrors TEXT_CODE_EXTENSIONS in
|
||||
# ap-web/src/lib/attachments.ts — keep in sync.
|
||||
_TEXT_CODE_EXTENSIONS: frozenset[str] = frozenset(
|
||||
{
|
||||
".txt",
|
||||
".log",
|
||||
".md",
|
||||
".markdown",
|
||||
".csv",
|
||||
".tsv",
|
||||
".json",
|
||||
".jsonl",
|
||||
".ndjson",
|
||||
".yaml",
|
||||
".yml",
|
||||
".toml",
|
||||
".ini",
|
||||
".cfg",
|
||||
".env",
|
||||
".lock",
|
||||
".proto",
|
||||
".graphql",
|
||||
".gql",
|
||||
".html",
|
||||
".htm",
|
||||
".xml",
|
||||
".css",
|
||||
".js",
|
||||
".jsx",
|
||||
".mjs",
|
||||
".cjs",
|
||||
".ts",
|
||||
".tsx",
|
||||
".py",
|
||||
".rb",
|
||||
".go",
|
||||
".rs",
|
||||
".java",
|
||||
".kt",
|
||||
".scala",
|
||||
".swift",
|
||||
".c",
|
||||
".h",
|
||||
".cc",
|
||||
".cpp",
|
||||
".hpp",
|
||||
".cs",
|
||||
".php",
|
||||
".pl",
|
||||
".r",
|
||||
".jl",
|
||||
".lua",
|
||||
".ex",
|
||||
".exs",
|
||||
".erl",
|
||||
".hs",
|
||||
".clj",
|
||||
".dart",
|
||||
".vue",
|
||||
".svelte",
|
||||
".sh",
|
||||
".bash",
|
||||
".zsh",
|
||||
".fish",
|
||||
".sql",
|
||||
".tf",
|
||||
".hcl",
|
||||
".gradle",
|
||||
".dockerfile",
|
||||
".ipynb",
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def attachment_text_type_for_extension(filename: str | None) -> str | None:
|
||||
"""
|
||||
Resolve a text-like MIME for *filename* by extension, or ``None``.
|
||||
|
||||
Used as a fallback when the upload's declared MIME mislabels a text/code
|
||||
file as binary (e.g. a ``.csv`` reported as ``application/vnd.ms-excel``):
|
||||
only extensions in :data:`_TEXT_CODE_EXTENSIONS` are honored, so a real
|
||||
binary (``.xls``, ``.pptx``) is never re-admitted. Mirrors the web
|
||||
client's extension allowlist so the two agree on what's attachable.
|
||||
|
||||
:param filename: The original filename, e.g. ``"data.csv"``.
|
||||
:returns: A concrete text-like MIME (e.g. ``"text/csv"``), or ``None``
|
||||
when the extension is not a recognised text/code type.
|
||||
"""
|
||||
import mimetypes as _mt
|
||||
from pathlib import PurePath
|
||||
|
||||
if not filename:
|
||||
return None
|
||||
suffix = PurePath(filename).suffix.lower()
|
||||
if suffix not in _TEXT_CODE_EXTENSIONS:
|
||||
return None
|
||||
mapped = _EXTRA_MIME_TYPES.get(suffix)
|
||||
if mapped:
|
||||
return mapped
|
||||
guessed = _mt.guess_type(filename)[0]
|
||||
if guessed and (guessed.startswith("text/") or guessed in _TEXT_LIKE_APPLICATION_MIMES):
|
||||
return guessed
|
||||
return "text/plain"
|
||||
|
||||
|
||||
def resolve_content_references(
|
||||
items: list[ConversationItem],
|
||||
file_store: FileStore,
|
||||
|
||||
@@ -12353,6 +12353,44 @@ async def _handle_mcp_tools_call(
|
||||
)
|
||||
|
||||
|
||||
# Read uploads in 1 MiB chunks so an oversized body is aborted ~1 MiB past
|
||||
# the cap instead of being buffered whole (the previous unconditional
|
||||
# ``await file.read()`` was an OOM risk for very large uploads).
|
||||
_UPLOAD_READ_CHUNK_BYTES: int = 1024 * 1024
|
||||
|
||||
|
||||
async def _read_upload_capped(file: UploadFile, limit_bytes: int) -> bytes:
|
||||
"""
|
||||
Read an uploaded file into memory, aborting if it exceeds *limit_bytes*.
|
||||
|
||||
Reads in :data:`_UPLOAD_READ_CHUNK_BYTES` chunks and raises HTTP 413 as
|
||||
soon as the cap is crossed, so an oversized upload never buffers more
|
||||
than one chunk past the limit.
|
||||
|
||||
:param file: The multipart upload.
|
||||
:param limit_bytes: Maximum allowed size in bytes.
|
||||
:returns: The full file content.
|
||||
:raises HTTPException: 413 when the upload exceeds *limit_bytes*.
|
||||
"""
|
||||
chunks: list[bytes] = []
|
||||
total = 0
|
||||
while True:
|
||||
chunk = await file.read(_UPLOAD_READ_CHUNK_BYTES)
|
||||
if not chunk:
|
||||
break
|
||||
total += len(chunk)
|
||||
if total > limit_bytes:
|
||||
raise HTTPException(
|
||||
status_code=413,
|
||||
detail=(
|
||||
f"Attachment exceeds the {limit_bytes // (1024 * 1024)} MB "
|
||||
"limit for this file type."
|
||||
),
|
||||
)
|
||||
chunks.append(chunk)
|
||||
return b"".join(chunks)
|
||||
|
||||
|
||||
def create_sessions_router(
|
||||
conversation_store: ConversationStore,
|
||||
agent_store: AgentStore,
|
||||
@@ -15700,15 +15738,45 @@ def create_sessions_router(
|
||||
"filename is required",
|
||||
code=ErrorCode.INVALID_INPUT,
|
||||
)
|
||||
content = await file.read()
|
||||
from omnigent.runtime.content_resolver import (
|
||||
MAX_ATTACHMENT_UPLOAD_BYTES,
|
||||
_resolve_content_type,
|
||||
attachment_text_type_for_extension,
|
||||
attachment_upload_limit,
|
||||
)
|
||||
|
||||
# Resolve the type from the declared MIME + filename BEFORE reading
|
||||
# the body, so an unsupported or oversized upload is rejected without
|
||||
# buffering it. Attachments are inlined into the model context as
|
||||
# base64 (see content_resolver.resolve_content_references); only
|
||||
# images, PDF, and text/code files are usable — others (pptx, docx,
|
||||
# zip, …) would be garbled or blow the request size, so reject them.
|
||||
content_type = _resolve_content_type(
|
||||
file.content_type,
|
||||
file.filename,
|
||||
)
|
||||
type_limit = attachment_upload_limit(content_type)
|
||||
if type_limit is None:
|
||||
# The browser/OS can mislabel a text/code file as binary (e.g. a
|
||||
# .csv reported as application/vnd.ms-excel on Windows). Fall back
|
||||
# to the extension — matching the web client's allowlist — and
|
||||
# normalize the type so the resolver inlines it as text.
|
||||
ext_type = attachment_text_type_for_extension(file.filename)
|
||||
if ext_type is not None:
|
||||
content_type = ext_type
|
||||
type_limit = attachment_upload_limit(content_type)
|
||||
if type_limit is None:
|
||||
raise HTTPException(
|
||||
status_code=415,
|
||||
detail=(
|
||||
f"Unsupported attachment type '{content_type}'. Only images, "
|
||||
"PDF, and text/code files can be attached."
|
||||
),
|
||||
)
|
||||
content = await _read_upload_capped(
|
||||
file,
|
||||
min(type_limit, MAX_ATTACHMENT_UPLOAD_BYTES),
|
||||
)
|
||||
stored = file_store.create(
|
||||
session_id=session_id,
|
||||
filename=file.filename,
|
||||
|
||||
@@ -28,11 +28,15 @@ from playwright.sync_api import Page, expect
|
||||
_COMPOSER = "Ask the agent anything…"
|
||||
# Composer accepts image/*,application/pdf,text/*,application/json (the hidden
|
||||
# input's accept attr); a .txt file is in-scope and keeps the fixture trivial.
|
||||
# ``set_input_files`` bypasses the accept filter anyway — ``addFiles`` does no
|
||||
# client-side filtering.
|
||||
# ``set_input_files`` bypasses the accept filter, but ``addFiles`` now validates
|
||||
# every file (type + size, via lib/attachments.ts) — a .txt passes both.
|
||||
_ATTACH_NAME = "attach_sample.txt"
|
||||
_ATTACH_BODY = "composer attachment e2e sample\n"
|
||||
|
||||
# An unsupported binary type: ``addFiles`` rejects it (no chip) and shows an
|
||||
# inline error. Used by ``test_reject_unsupported_type``.
|
||||
_PPTX_NAME = "deck.pptx"
|
||||
|
||||
# JSON is its own MIME (``application/json``), which is NOT covered by the
|
||||
# ``text/*`` wildcard, so it has to be listed in the ``accept`` attr explicitly
|
||||
# for the OS picker (and the drag-drop ``matchesAccept`` validator) to admit it.
|
||||
@@ -101,3 +105,31 @@ def test_attach_json_file(page: Page, seeded_session: tuple[str, str], tmp_path:
|
||||
remove_button = page.get_by_role("button", name=f"Remove {_JSON_NAME}")
|
||||
expect(remove_button).to_be_visible(timeout=10_000)
|
||||
expect(page.get_by_text(_JSON_NAME, exact=True)).to_be_visible()
|
||||
|
||||
|
||||
def test_reject_unsupported_type(
|
||||
page: Page, seeded_session: tuple[str, str], tmp_path: Path
|
||||
) -> None:
|
||||
"""An unsupported type (pptx) is rejected client-side: no chip, inline error.
|
||||
|
||||
Covers the validation ``addFiles`` gained (``validateAttachments`` in
|
||||
lib/attachments.ts): only images, PDF, and text/code files attach; office /
|
||||
binary formats are rejected before upload with a per-file message. Driving
|
||||
the hidden input with a ``.pptx`` (``set_input_files`` bypasses the accept
|
||||
filter, so the file reaches ``addFiles``) must yield NO chip and a visible
|
||||
rejection error.
|
||||
"""
|
||||
base_url, session_id = seeded_session
|
||||
sample = tmp_path / _PPTX_NAME
|
||||
sample.write_bytes(b"PK\x03\x04 not a real pptx, just an unsupported binary")
|
||||
|
||||
page.goto(f"{base_url}/c/{session_id}")
|
||||
expect(page.get_by_placeholder(_COMPOSER)).to_be_visible(timeout=30_000)
|
||||
|
||||
file_input = page.locator('input[type="file"][accept*="image/"]')
|
||||
file_input.set_input_files(str(sample))
|
||||
|
||||
# Rejected: no chip / remove control for the file.
|
||||
expect(page.get_by_role("button", name=f"Remove {_PPTX_NAME}")).to_have_count(0)
|
||||
# And the inline rejection error is shown.
|
||||
expect(page.get_by_text("can't be attached", exact=False)).to_be_visible(timeout=10_000)
|
||||
|
||||
@@ -918,3 +918,156 @@ def test_resolve_image_file_keeps_specific_mime(
|
||||
assert image_block["image_url"].startswith("data:image/png;base64,"), (
|
||||
f"Expected image/png data URI, got: {image_block['image_url'][:60]}"
|
||||
)
|
||||
|
||||
|
||||
# ── Attachment upload limits ──────────────────────────────────────────
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("content_type", "expected_mb"),
|
||||
[
|
||||
("image/png", 5),
|
||||
("image/jpeg", 5),
|
||||
("image/webp", 5),
|
||||
("application/pdf", 20),
|
||||
("text/plain", 10),
|
||||
("text/markdown", 10),
|
||||
("text/x-python", 10),
|
||||
("text/typescript", 10),
|
||||
("application/json", 10),
|
||||
("application/x-ipynb+json", 10),
|
||||
],
|
||||
)
|
||||
def test_attachment_upload_limit_allowed_types(content_type: str, expected_mb: int) -> None:
|
||||
"""Images, PDF, and text-like types get their per-type byte cap."""
|
||||
from omnigent.runtime.content_resolver import attachment_upload_limit
|
||||
|
||||
assert attachment_upload_limit(content_type) == expected_mb * 1024 * 1024
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"content_type",
|
||||
[
|
||||
"application/vnd.openxmlformats-officedocument.presentationml.presentation", # pptx
|
||||
"application/vnd.openxmlformats-officedocument.wordprocessingml.document", # docx
|
||||
"application/vnd.ms-excel",
|
||||
"application/zip",
|
||||
"application/octet-stream",
|
||||
"audio/mpeg",
|
||||
"video/mp4",
|
||||
],
|
||||
)
|
||||
def test_attachment_upload_limit_rejects_unsupported_types(content_type: str) -> None:
|
||||
"""Office/binary/media types are not uploadable (None ⇒ caller 415s)."""
|
||||
from omnigent.runtime.content_resolver import attachment_upload_limit
|
||||
|
||||
assert attachment_upload_limit(content_type) is None
|
||||
|
||||
|
||||
def test_attachment_upload_limits_are_under_global_ceiling() -> None:
|
||||
"""Every per-type limit stays within the global request-size backstop."""
|
||||
from omnigent.runtime.content_resolver import (
|
||||
MAX_ATTACHMENT_UPLOAD_BYTES,
|
||||
MAX_IMAGE_UPLOAD_BYTES,
|
||||
MAX_PDF_UPLOAD_BYTES,
|
||||
MAX_TEXT_UPLOAD_BYTES,
|
||||
)
|
||||
|
||||
assert MAX_IMAGE_UPLOAD_BYTES <= MAX_ATTACHMENT_UPLOAD_BYTES
|
||||
assert MAX_PDF_UPLOAD_BYTES <= MAX_ATTACHMENT_UPLOAD_BYTES
|
||||
assert MAX_TEXT_UPLOAD_BYTES <= MAX_ATTACHMENT_UPLOAD_BYTES
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("filename", "expected"),
|
||||
[
|
||||
("data.csv", "text/csv"),
|
||||
("notes.txt", "text/plain"),
|
||||
("main.py", "text/x-python"),
|
||||
("app.ts", "text/typescript"),
|
||||
("readme.md", "text/markdown"),
|
||||
("nb.ipynb", "application/x-ipynb+json"),
|
||||
],
|
||||
)
|
||||
def test_attachment_text_type_for_extension_recognised(filename: str, expected: str) -> None:
|
||||
"""Known text/code extensions resolve to a text-like MIME (the fallback
|
||||
used when the declared MIME mislabels them as binary)."""
|
||||
from omnigent.runtime.content_resolver import attachment_text_type_for_extension
|
||||
|
||||
assert attachment_text_type_for_extension(filename) == expected
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"filename",
|
||||
["sheet.xls", "sheet.xlsx", "deck.pptx", "doc.docx", "archive.zip", "blob", None],
|
||||
)
|
||||
def test_attachment_text_type_for_extension_rejects_binary(filename: str | None) -> None:
|
||||
"""Real binaries (and missing/unknown extensions) get no text fallback,
|
||||
so they stay rejected even if the declared MIME is wrong."""
|
||||
from omnigent.runtime.content_resolver import attachment_text_type_for_extension
|
||||
|
||||
assert attachment_text_type_for_extension(filename) is None
|
||||
|
||||
|
||||
def test_text_code_extensions_resolve_to_allowed_text() -> None:
|
||||
"""Every declared text/code extension resolves to a text-like type that
|
||||
has an upload limit — so the route's extension fallback admits it (no 415),
|
||||
regardless of the browser-reported MIME."""
|
||||
from omnigent.runtime.content_resolver import (
|
||||
_TEXT_CODE_EXTENSIONS,
|
||||
attachment_text_type_for_extension,
|
||||
attachment_upload_limit,
|
||||
)
|
||||
|
||||
for ext in _TEXT_CODE_EXTENSIONS:
|
||||
mime = attachment_text_type_for_extension(f"file{ext}")
|
||||
assert mime is not None, f"{ext} resolved to no text type"
|
||||
assert attachment_upload_limit(mime) is not None, f"{ext} -> {mime} has no limit"
|
||||
|
||||
|
||||
def test_client_server_attachment_extension_parity() -> None:
|
||||
"""The web client's TEXT_CODE_EXTENSIONS must all be accepted server-side,
|
||||
even when the browser reports a non-text MIME — the parity contract the two
|
||||
share. Guards against the client gate admitting a file the upload route then
|
||||
415s (the divergence Polly flagged)."""
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
from omnigent.runtime.content_resolver import (
|
||||
_resolve_content_type,
|
||||
attachment_text_type_for_extension,
|
||||
attachment_upload_limit,
|
||||
)
|
||||
|
||||
ts_path = Path(__file__).resolve().parents[2] / "ap-web" / "src" / "lib" / "attachments.ts"
|
||||
if not ts_path.exists():
|
||||
pytest.skip("ap-web/src/lib/attachments.ts not present (server-only checkout)")
|
||||
block = ts_path.read_text().split("TEXT_CODE_EXTENSIONS = new Set([")[1].split("]")[0]
|
||||
client_exts = re.findall(r'"(\.[a-z0-9]+)"', block)
|
||||
assert client_exts, "could not parse client TEXT_CODE_EXTENSIONS"
|
||||
|
||||
# MIMEs a browser/OS might attach to these extensions, including wrong ones.
|
||||
worst_case_mimes = [
|
||||
"",
|
||||
"application/octet-stream",
|
||||
"video/mp2t", # .ts
|
||||
"application/xml", # .xml
|
||||
"application/x-ruby", # .rb
|
||||
]
|
||||
|
||||
def server_accepts(name: str, browser_mime: str) -> bool:
|
||||
content_type = _resolve_content_type(browser_mime, name)
|
||||
limit = attachment_upload_limit(content_type)
|
||||
if limit is None:
|
||||
ext_type = attachment_text_type_for_extension(name)
|
||||
if ext_type is not None:
|
||||
limit = attachment_upload_limit(ext_type)
|
||||
return limit is not None
|
||||
|
||||
rejected = [
|
||||
(ext, mime)
|
||||
for ext in client_exts
|
||||
for mime in worst_case_mimes
|
||||
if not server_accepts(f"file{ext}", mime)
|
||||
]
|
||||
assert not rejected, f"client accepts but server would 415: {rejected}"
|
||||
|
||||
@@ -0,0 +1,155 @@
|
||||
"""Attachment upload type/size enforcement on POST /v1/sessions/{id}/resources/files."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Iterator
|
||||
|
||||
import pytest
|
||||
from fastapi import FastAPI, Request
|
||||
from fastapi.responses import JSONResponse
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from omnigent.errors import OmnigentError
|
||||
from omnigent.runtime.content_resolver import (
|
||||
MAX_IMAGE_UPLOAD_BYTES,
|
||||
MAX_TEXT_UPLOAD_BYTES,
|
||||
)
|
||||
from omnigent.server.routes.sessions import create_sessions_router
|
||||
from omnigent.stores.agent_store.sqlalchemy_store import SqlAlchemyAgentStore
|
||||
from omnigent.stores.artifact_store.local import LocalArtifactStore
|
||||
from omnigent.stores.conversation_store.sqlalchemy_store import (
|
||||
SqlAlchemyConversationStore,
|
||||
)
|
||||
from omnigent.stores.file_store.sqlalchemy_store import SqlAlchemyFileStore
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def upload_client(db_uri: str, tmp_path) -> Iterator[tuple[TestClient, str]]:
|
||||
"""A sessions route client with file + artifact stores and one session."""
|
||||
conversation_store = SqlAlchemyConversationStore(db_uri)
|
||||
agent_store = SqlAlchemyAgentStore(db_uri)
|
||||
file_store = SqlAlchemyFileStore(db_uri)
|
||||
artifact_store = LocalArtifactStore(str(tmp_path / "artifacts"))
|
||||
agent_store.create(
|
||||
agent_id="ag_test",
|
||||
name="test-agent",
|
||||
bundle_location="ag_test/bundle",
|
||||
)
|
||||
conv = conversation_store.create_conversation(title="upload session", agent_id="ag_test")
|
||||
|
||||
app = FastAPI()
|
||||
|
||||
@app.exception_handler(OmnigentError)
|
||||
async def _handle_omnigent_error(request: Request, exc: OmnigentError) -> JSONResponse:
|
||||
del request
|
||||
return JSONResponse(
|
||||
status_code=exc.http_status,
|
||||
content={"error": {"code": exc.code, "message": exc.message}},
|
||||
)
|
||||
|
||||
app.include_router(
|
||||
create_sessions_router(
|
||||
conversation_store=conversation_store,
|
||||
agent_store=agent_store,
|
||||
file_store=file_store,
|
||||
artifact_store=artifact_store,
|
||||
),
|
||||
prefix="/v1",
|
||||
)
|
||||
|
||||
with TestClient(app) as client:
|
||||
yield client, conv.id
|
||||
|
||||
|
||||
def test_upload_small_text_file_succeeds(upload_client: tuple[TestClient, str]) -> None:
|
||||
"""A small text file uploads and returns a resource."""
|
||||
client, session_id = upload_client
|
||||
resp = client.post(
|
||||
f"/v1/sessions/{session_id}/resources/files",
|
||||
files={"file": ("notes.txt", b"hello world", "text/plain")},
|
||||
)
|
||||
assert resp.status_code in (200, 201), resp.text
|
||||
body = resp.json()
|
||||
assert body["name"] == "notes.txt"
|
||||
|
||||
|
||||
def test_upload_rejects_unsupported_type(upload_client: tuple[TestClient, str]) -> None:
|
||||
"""A pptx (binary office doc) is rejected with 415, not stored."""
|
||||
client, session_id = upload_client
|
||||
pptx_mime = "application/vnd.openxmlformats-officedocument.presentationml.presentation"
|
||||
resp = client.post(
|
||||
f"/v1/sessions/{session_id}/resources/files",
|
||||
files={"file": ("deck.pptx", b"PK\x03\x04 fake pptx bytes", pptx_mime)},
|
||||
)
|
||||
assert resp.status_code == 415, resp.text
|
||||
assert "Unsupported attachment type" in resp.text
|
||||
|
||||
|
||||
def test_upload_rejects_oversized_image(upload_client: tuple[TestClient, str]) -> None:
|
||||
"""An image over the per-type limit is rejected with 413."""
|
||||
client, session_id = upload_client
|
||||
oversized = b"\x00" * (MAX_IMAGE_UPLOAD_BYTES + 1)
|
||||
resp = client.post(
|
||||
f"/v1/sessions/{session_id}/resources/files",
|
||||
files={"file": ("huge.png", oversized, "image/png")},
|
||||
)
|
||||
assert resp.status_code == 413, resp.status_code
|
||||
assert "limit" in resp.text.lower()
|
||||
|
||||
|
||||
def test_upload_csv_mislabeled_as_excel_is_accepted(
|
||||
upload_client: tuple[TestClient, str],
|
||||
) -> None:
|
||||
"""A .csv the browser tags application/vnd.ms-excel is accepted via the
|
||||
extension fallback and stored as a text type (parity with the web client)."""
|
||||
client, session_id = upload_client
|
||||
resp = client.post(
|
||||
f"/v1/sessions/{session_id}/resources/files",
|
||||
files={"file": ("data.csv", b"a,b,c\n1,2,3\n", "application/vnd.ms-excel")},
|
||||
)
|
||||
assert resp.status_code in (200, 201), resp.text
|
||||
assert resp.json()["name"] == "data.csv"
|
||||
|
||||
|
||||
def test_upload_text_just_under_limit_succeeds(upload_client: tuple[TestClient, str]) -> None:
|
||||
"""A text file just under the text cap is accepted."""
|
||||
client, session_id = upload_client
|
||||
payload = b"a" * (MAX_TEXT_UPLOAD_BYTES - 1024)
|
||||
resp = client.post(
|
||||
f"/v1/sessions/{session_id}/resources/files",
|
||||
files={"file": ("big.txt", payload, "text/plain")},
|
||||
)
|
||||
assert resp.status_code in (200, 201), resp.status_code
|
||||
|
||||
|
||||
class _FakeUpload:
|
||||
"""Minimal UploadFile stand-in exposing the chunked ``read`` interface."""
|
||||
|
||||
def __init__(self, data: bytes) -> None:
|
||||
self._data = data
|
||||
self._pos = 0
|
||||
|
||||
async def read(self, size: int) -> bytes:
|
||||
chunk = self._data[self._pos : self._pos + size]
|
||||
self._pos += len(chunk)
|
||||
return chunk
|
||||
|
||||
|
||||
async def test_read_upload_capped_allows_exactly_at_limit() -> None:
|
||||
"""A payload exactly at the limit is accepted (the ``>`` boundary)."""
|
||||
from omnigent.server.routes.sessions import _read_upload_capped
|
||||
|
||||
data = b"x" * 100
|
||||
assert await _read_upload_capped(_FakeUpload(data), 100) == data
|
||||
|
||||
|
||||
async def test_read_upload_capped_rejects_one_over_limit() -> None:
|
||||
"""One byte over the limit raises HTTP 413."""
|
||||
import pytest as _pytest
|
||||
from fastapi import HTTPException
|
||||
|
||||
from omnigent.server.routes.sessions import _read_upload_capped
|
||||
|
||||
with _pytest.raises(HTTPException) as exc_info:
|
||||
await _read_upload_capped(_FakeUpload(b"x" * 101), 100)
|
||||
assert exc_info.value.status_code == 413
|
||||
Reference in New Issue
Block a user