Compare commits

...

4 Commits

Author SHA1 Message Date
Kecheng Cao 5b30a73142 test(attachments): guard client/server extension parity and the cap boundary
Polly review follow-up. The client gate (TEXT_CODE_EXTENSIONS in
attachments.ts) and the server's extension fallback must agree on what's
attachable, or a file passes the client and then 415s. Add:

- test_client_server_attachment_extension_parity: parses the client's
  TEXT_CODE_EXTENSIONS and asserts every one is accepted server-side across
  worst-case browser MIMEs (.ts→video/mp2t, .xml→application/xml,
  .rb→application/x-ruby, octet-stream, empty) — the divergence Polly flagged,
  now covered.
- test_text_code_extensions_resolve_to_allowed_text: every declared extension
  resolves to a limited text type.
- _read_upload_capped boundary tests: exactly-at-limit passes, one-over 413s.
2026-06-24 02:36:33 +00:00
Kecheng Cao c031432b0c test(attachments): add e2e_ui reject-type coverage; prettier-format web files
- Format lib/attachments.ts + attachments.test.ts to the project's prettier
  style (fixes the ap-web-prettier pre-commit hook and npm test's format check).
- tests/e2e_ui/chat/test_composer_attachments.py: add test_reject_unsupported_type
  — drives a .pptx through the composer's hidden input and asserts no chip plus
  the inline rejection error (Playwright coverage the E2E UI gate requires for
  the new addFiles validation). Update the stale "no client-side filtering"
  comment now that addFiles validates type + size.
2026-06-24 02:17:17 +00:00
Kecheng Cao cd65d98a3b fix(attachments): accept text/code files mislabeled as binary (e.g. .csv as Excel)
Some browsers/OSes report a text/code file's MIME as a binary office type
(notably .csv → application/vnd.ms-excel on Windows). The server's type
check would then 415 it, even though the web client accepts it via its
extension allowlist — a frontend/backend mismatch.

Add attachment_text_type_for_extension(): when the declared MIME isn't an
allowed attachment, fall back to a text-like type by extension (mirroring
the web allowlist), but only for known text/code extensions so real
binaries (.xls, .pptx) stay rejected. The upload route normalizes the
content_type to the resolved text type so the resolver inlines it as text.
2026-06-24 01:48:49 +00:00
Kecheng Cao 6fac046dd4 feat(attachments): enforce per-type upload size limits and block unsupported types
Uploaded attachments are inlined into the model context as base64 and
re-sent every turn, so a large or unreadable file either blows the
context budget (the ~65MB pptx that crash-looped a session) or is fed to
the model as garbled UTF-8. There was no size or type guard: the upload
route read the whole body unconditionally and accepted anything.

Server (authoritative):
- content_resolver.attachment_upload_limit(content_type) returns a
  per-type byte cap (image 5MB / PDF 20MB / text 10MB; 25MB global
  ceiling) or None for unsupported types (pptx, docx, zip, ...).
- upload_session_file resolves the type BEFORE reading the body and
  returns 415 for unsupported types; reads via _read_upload_capped,
  which aborts with 413 once the per-type cap is crossed (also fixes the
  unbounded read OOM risk).

Web (early UX block):
- lib/attachments.ts: classifyAttachment / validateAttachments mirror the
  server limits; code files whose browser MIME is empty/wrong are matched
  by extension.
- ChatPage.addFiles validates paste/drop/picker input, keeps only
  accepted files, and shows an inline error for rejected ones.

Tests: attachment_upload_limit matrix, upload endpoint 415/413/happy
paths, and lib/attachments unit tests.
2026-06-24 00:54:17 +00:00
8 changed files with 833 additions and 5 deletions
+73
View File
@@ -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);
});
});
+165
View File
@@ -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 };
}
+19 -2
View File
@@ -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">
+165
View File
@@ -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,
+69 -1
View File
@@ -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,
+34 -2
View File
@@ -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)
+153
View File
@@ -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