feat(uploads): prompt uploads take documents

The Attach button, the Compose and paste-dialog pickers, terminal drop and the
clipboard now take documents beside images and videos: the office formats
pdf/docx/pptx/xlsx/zip and the text family txt/md/csv/tsv/json/log/yaml/yml/xml.

A document is admitted by its name's extension (the MIME a phone attaches to a
.md is anyone's guess), buffered under the same 50MB image cap, and judged on
its bytes by the same fail-closed sniffer: a magic-number signature for the
binary formats, UTF-8 with no NUL byte for text. It is saved under a sanitized
stem of its name, so Q3 report.pdf is findable as paste-<ts>-<hex>-Q3-report.pdf.

sh/py/js/html are refused by name with a clear 400. That is a scope choice, not
a security property: file-raw already serves non-media text inert, so this keeps
the server from storing what it would serve or an agent could run, and html and
svg are the ones deliberately kept out. One client classifier (_promptAttachKind)
now returns image, video or document, and the server's dispatch mirrors it: an
image MIME wins whatever the name says, a document extension takes the document
branch, and a foreign extension with no image MIME is refused only after the
image sniff fails, so nothing the image path accepted by its bytes is lost.
This commit is contained in:
JD committed 2026-10-13 20:31:39 -04:00
1 parent a8a8bb71a3
commit 244d9a49f1
18 files changed
+382 -96

No files matched your search

+32 -6
View File
@@ -45,8 +45,14 @@ function loadComposer(sessionId = 'session-1') {
_predictiveEcho: { clearPredictions: vi.fn() },
_sendInputAsync: vi.fn(),
_uploadAndInsertImages: vi.fn(async () => ['/tmp/image-one.png']),
_promptAttachKind: (file: { type: string }) =>
file.type.startsWith('image/') ? 'image' : file.type.startsWith('video/') ? 'video' : null,
_promptAttachKind: (file: { type: string; name?: string }) =>
file.type.startsWith('image/')
? 'image'
: file.type.startsWith('video/')
? 'video'
: /\.(pdf|docx|txt|md)$/i.test(file.name || '')
? 'document'
: null,
showToast: vi.fn(),
};
const schedule = (callback: () => void, delay = 0) => {
@@ -576,20 +582,40 @@ describe('mobile prompt composer', () => {
expect((document.querySelector('.paste-send') as HTMLButtonElement).disabled).toBe(false);
});
it('offers videos in the Image key picker and filters the pick through the shared classifier', async () => {
it('offers videos and documents in the Attach key picker and filters the pick through the shared classifier', async () => {
const { app, bar, document } = loadComposer();
bar.composePrompt();
const fileInput = document.querySelector('.paste-file-input') as HTMLInputElement;
expect(fileInput.getAttribute('accept')).toBe('image/*,video/*');
expect(fileInput.getAttribute('accept')).toBe(
'image/*,video/*,.pdf,.docx,.pptx,.xlsx,.zip,.txt,.md,.csv,.tsv,.json,.log,.yaml,.yml,.xml'
);
expect((document.querySelector('.paste-image') as HTMLButtonElement).textContent).toBe('📎 Attach');
const video = new document.defaultView!.File(['clip'], 'clip.mov', { type: 'video/quicktime' });
const pdf = new document.defaultView!.File(['%PDF'], 'notes.pdf', { type: 'application/pdf' });
Object.defineProperty(fileInput, 'files', { configurable: true, value: [video, pdf] });
const script = new document.defaultView!.File(['#!/bin/sh'], 'run.sh', { type: 'text/x-shellscript' });
Object.defineProperty(fileInput, 'files', { configurable: true, value: [video, pdf, script] });
fileInput.dispatchEvent(new document.defaultView!.Event('change', { bubbles: true }));
expect(app._uploadAndInsertImages).toHaveBeenCalledWith([video], { insert: false });
expect(app._uploadAndInsertImages).toHaveBeenCalledWith([video, pdf], { insert: false });
await vi.waitFor(() => expect(textarea(document).value).toContain('/tmp/image-one.png'));
});
it('puts an uploaded document path into the draft like a photo', async () => {
const { app, bar, document } = loadComposer();
app._uploadAndInsertImages.mockResolvedValueOnce(['/case/.codeman-uploads/paste-1-ab-notes.pdf']);
bar.composePrompt();
const input = textarea(document);
input.value = 'summarize';
input.selectionStart = input.selectionEnd = input.value.length;
input.dispatchEvent(new document.defaultView!.Event('input', { bubbles: true }));
const fileInput = document.querySelector('.paste-file-input') as HTMLInputElement;
const pdf = new document.defaultView!.File(['%PDF'], 'notes.pdf', { type: '' }); // no MIME: the name decides
Object.defineProperty(fileInput, 'files', { configurable: true, value: [pdf] });
fileInput.dispatchEvent(new document.defaultView!.Event('change', { bubbles: true }));
await vi.waitFor(() => expect(input.value).toBe('summarize /case/.codeman-uploads/paste-1-ab-notes.pdf'));
expect(app.terminal.paste).not.toHaveBeenCalled();
});
it('takes a video pasted into the editor through the shared classifier', () => {
const { app, bar, document } = loadComposer();
bar.composePrompt();