From 2cfa2783adccb0c8939508dcb2c7ed8513247908 Mon Sep 17 00:00:00 2001 From: JD Date: Wed, 14 Oct 2026 21:58:05 -0400 Subject: [PATCH 1/2] fix(uploads): say how many files a mixed pick, drop or paste skipped A selection mixing a file the server takes with one it refuses dropped the refused one without a word, and the Compose and paste-dialog pickers never toasted even when every file was refused. Every pick, drop and paste filter now goes through one helper that counts what the classifier refused: a partial refusal toasts how many were skipped while the rest upload, a full one keeps the existing Unsupported file type toast, and a clipboard paste that falls back to its text stays quiet. --- src/web/public/i18n.js | 2 + src/web/public/image-input.js | 38 +++++++++----- src/web/public/keyboard-accessory.js | 45 ++++++++++------ src/web/public/tile-grid.js | 9 ++-- test/image-paste-trap.test.ts | 36 ++++++++++++- test/mobile-prompt-composer.test.ts | 78 +++++++++++++++++++++++++++- test/mocks/tile-grid-vm.ts | 2 +- test/tile-grid-file-drop.test.ts | 8 +-- test/tile-grid-i18n.test.ts | 9 ++++ test/toolbar-attach.test.ts | 45 +++++++++++++++- 10 files changed, 228 insertions(+), 44 deletions(-) diff --git a/src/web/public/i18n.js b/src/web/public/i18n.js index 42657a23..3a9d4b75 100644 --- a/src/web/public/i18n.js +++ b/src/web/public/i18n.js @@ -1319,6 +1319,8 @@ [/^Worktree (\S+) and branch (\S+) removed$/, (_m, wt, branch) => `已移除工作树 ${wt} 和分支 ${branch}`], [/^Worktree (\S+) removed$/, (_m, wt) => `已移除工作树 ${wt}`], [/^Worktree (\S+) was kept: ([\s\S]+)$/, (_m, wt, why) => `工作树 ${wt} 已保留:${why}`], + // A pick, drop or paste that mixed admitted files with refused ones (image-input.js). + [/^(\d+) files? skipped: unsupported type$/, (_m, n) => `已跳过 ${n} 个不支持类型的文件`], // Group names are user text: they pass through untranslated. [/^Move to "(.+)"$/, (_m, group) => `移到“${group}”`], // Session names are user text too. diff --git a/src/web/public/image-input.js b/src/web/public/image-input.js index a0b27ba2..a0ec33c3 100644 --- a/src/web/public/image-input.js +++ b/src/web/public/image-input.js @@ -101,12 +101,8 @@ Object.assign(CodemanApp.prototype, { if (!this.activeSessionId) return; if (!e.dataTransfer || !e.dataTransfer.files.length) return; - const mediaFiles = Array.from(e.dataTransfer.files).filter((f) => this._promptAttachKind(f)); - if (mediaFiles.length === 0) { - this.showToast('Unsupported file type', 'error'); - return; - } - this._uploadAndInsertImages(mediaFiles); + const mediaFiles = this._admitPromptAttachments(Array.from(e.dataTransfer.files)); + if (mediaFiles.length > 0) this._uploadAndInsertImages(mediaFiles); }); }, @@ -122,11 +118,8 @@ Object.assign(CodemanApp.prototype, { * Without the overlay (local echo off) there is nothing to buffer into, so the * text goes straight out. The trailing space keeps the next word off the path. */ async _attachFromToolbar(files, sessionId) { - const media = files.filter((f) => this._promptAttachKind(f)); - if (media.length === 0) { - this.showToast('Unsupported file type', 'error'); - return; - } + const media = this._admitPromptAttachments(files); + if (media.length === 0) return; if (!sessionId) return; // no captured session: refuse rather than guess at one const paths = await this._uploadAndInsertImages(media, { insert: false, sessionId }); if (paths.length === 0) return; @@ -231,14 +224,16 @@ Object.assign(CodemanApp.prototype, { pasteConsumed = true; // Check for images, videos or documents in clipboard items - var imageFiles = []; + var pastedFiles = []; var items = e.clipboardData && e.clipboardData.items; if (items) { for (var i = 0; i < items.length; i++) { var blob = items[i].getAsFile(); - if (blob && self._promptAttachKind(blob)) imageFiles.push(blob); + if (blob) pastedFiles.push(blob); } } + var text = e.clipboardData ? e.clipboardData.getData('text/plain') : ''; + var imageFiles = self._admitPromptAttachments(pastedFiles, { textPasted: !!text }); // Clean up the trap setTimeout(function() { @@ -256,7 +251,6 @@ Object.assign(CodemanApp.prototype, { // via raw sendInput() strips those markers and makes pasted input // indistinguishable from typed input, weakening the CLI's // prompt-injection defenses. - var text = e.clipboardData ? e.clipboardData.getData('text/plain') : ''; if (text && terminal) terminal.paste(text); } }); @@ -290,6 +284,22 @@ Object.assign(CodemanApp.prototype, { return null; }, + /** The files of a pick, drop or paste that _promptAttachKind admits, telling the + * user about any it refuses: all of them is "Unsupported file type", some of + * them a count of what was skipped while the rest still upload. A clipboard + * paste that also carries text passes `textPasted`: when none of its files is + * admitted the text is pasted instead, so that case stays quiet. */ + _admitPromptAttachments(files, { textPasted = false } = {}) { + const admitted = files.filter((f) => this._promptAttachKind(f)); + const skipped = files.length - admitted.length; + if (skipped > 0 && admitted.length > 0) { + this.showToast(`${skipped} file${skipped > 1 ? 's' : ''} skipped: unsupported type`, 'info'); + } else if (skipped > 0 && !textPasted) { + this.showToast('Unsupported file type', 'error'); + } + return admitted; + }, + // Max images accepted in one batch (paste / drop / mobile picker). Each is // uploaded as its own request, so 20 stays under the server's 30 uploads/min // rate limit while covering "select a bunch of photos at once". diff --git a/src/web/public/keyboard-accessory.js b/src/web/public/keyboard-accessory.js index e584e8f1..d76aa9c1 100644 --- a/src/web/public/keyboard-accessory.js +++ b/src/web/public/keyboard-accessory.js @@ -1339,8 +1339,9 @@ const KeyboardAccessoryBar = { this._syncComposerDraftIndicator(); close({ preserveDraft: false }); }; - const handleImages = async (files) => { - const images = Array.from(files || []).filter((file) => app._promptAttachKind(file)); + // Takes files already admitted by app._admitPromptAttachments (the picker and + // the paste below), which also toasts whatever it refused. + const handleImages = async (images) => { if (images.length === 0 || typeof app._uploadAndInsertImages !== 'function') return; saveDraft(); this._composerUploads.set(sessionId, (this._composerUploads.get(sessionId) || 0) + 1); @@ -1381,7 +1382,6 @@ const KeyboardAccessoryBar = { if (remaining > 0) this._composerUploads.set(sessionId, remaining); else this._composerUploads.delete(sessionId); syncUploadUi(); - if (overlay.isConnected) fileInput.value = ''; } }; @@ -1389,9 +1389,11 @@ const KeyboardAccessoryBar = { textarea.addEventListener('paste', (event) => { const items = event.clipboardData?.items; if (!items) return; - const images = Array.from(items) + const files = Array.from(items) .map((item) => item.getAsFile()) - .filter((file) => file && app._promptAttachKind(file)); + .filter(Boolean); + // Pasted text with no admitted file lands in the textarea natively: no toast. + const images = app._admitPromptAttachments(files, { textPasted: !!event.clipboardData.getData('text/plain') }); if (images.length > 0) { event.preventDefault(); void handleImages(images); @@ -1404,7 +1406,12 @@ const KeyboardAccessoryBar = { } }); imageButton.addEventListener('click', () => fileInput.click()); - fileInput.addEventListener('change', () => void handleImages(fileInput.files)); + fileInput.addEventListener('change', () => { + const files = Array.from(fileInput.files || []); + // Reset so picking the same refused file again still fires change (and its toast). + fileInput.value = ''; + void handleImages(app._admitPromptAttachments(files)); + }); overlay.querySelector('.prompt-composer-terminal').addEventListener('click', () => { // Hand the text BACK to the terminal prompt so both surfaces hold the same // prompt, the mirror of the adoption composePrompt() does on open. Only a @@ -1519,10 +1526,11 @@ const KeyboardAccessoryBar = { if (this._sendBracketedPaste(sessionId, text, 'Paste')) close(); }; - // Filter to what the server admits, close the dialog, and hand off to the - // shared upload+insert pipeline. Returns true if any file was handled. - const handleImages = (files) => { - const images = Array.from(files || []).filter((f) => app._promptAttachKind(f)); + // Filter to what the server admits (app._admitPromptAttachments toasts what it + // refuses), close the dialog, and hand off to the shared upload+insert + // pipeline. Returns true if any file was handled. + const handleImages = (files, options) => { + const images = app._admitPromptAttachments(Array.from(files || []), options); if (images.length === 0) return false; close(); // Upload to, and insert into, the shell this overlay was opened for. @@ -1532,21 +1540,24 @@ const KeyboardAccessoryBar = { // File picker (camera / photo library / files) — the reliable mobile path. overlay.querySelector('.paste-image').addEventListener('click', () => fileInput.click()); - fileInput.addEventListener('change', () => handleImages(fileInput.files)); + fileInput.addEventListener('change', () => { + const files = Array.from(fileInput.files || []); + // Reset so picking the same refused file again still fires change (and its toast). + fileInput.value = ''; + handleImages(files); + }); // Best-effort: capture images pasted straight into the textarea. textarea.addEventListener('paste', (e) => { const items = e.clipboardData && e.clipboardData.items; if (!items) return; - const imageFiles = []; + const files = []; for (let i = 0; i < items.length; i++) { const blob = items[i].getAsFile(); - if (blob && app._promptAttachKind(blob)) imageFiles.push(blob); - } - if (imageFiles.length > 0) { - e.preventDefault(); - handleImages(imageFiles); + if (blob) files.push(blob); } + // Pasted text with no admitted file lands in the textarea natively: no toast. + if (handleImages(files, { textPasted: !!e.clipboardData.getData('text/plain') })) e.preventDefault(); }); overlay.querySelector('.paste-cancel').addEventListener('click', close); diff --git a/src/web/public/tile-grid.js b/src/web/public/tile-grid.js index d95c6b27..018dd2f3 100644 --- a/src/web/public/tile-grid.js +++ b/src/web/public/tile-grid.js @@ -346,12 +346,9 @@ Object.assign(CodemanApp.prototype, { if (!sessionId || files.length === 0) return; // The one classifier every pick and drop filter reads (image-input.js): // MIME first, then the name's extension, so documents and videos count too. - const media = files.filter((f) => this._promptAttachKind?.(f)); - if (media.length === 0) { - this.showToast?.('Unsupported file type', 'error'); - return; - } - this._uploadAndInsertImages?.(media, { sessionId }); + // It toasts what it refuses. + const media = this._admitPromptAttachments(files); + if (media.length > 0) this._uploadAndInsertImages?.(media, { sessionId }); }); }, diff --git a/test/image-paste-trap.test.ts b/test/image-paste-trap.test.ts index c8bcca68..93370ae3 100644 --- a/test/image-paste-trap.test.ts +++ b/test/image-paste-trap.test.ts @@ -40,6 +40,8 @@ interface Harness { pastedText: string[]; /** Image batches handed to _uploadAndInsertImages(), one entry per call. */ uploadedBatches: Array>; + /** Messages handed to showToast(), one entry per call. */ + toasts: string[]; /** How many trap divs are still attached to the fake body. */ attachedTraps: () => number; runTimers: () => void; @@ -107,7 +109,8 @@ function loadPasteHarness(): Harness { app._uploadAndInsertImages = (files: Array<{ type: string }>) => { uploadedBatches.push(Array.from(files)); }; - app.showToast = () => {}; + const toasts: string[] = []; + app.showToast = (message: string) => toasts.push(message); (app._handleImagePaste as () => void).call(app); @@ -126,6 +129,7 @@ function loadPasteHarness(): Harness { }, pastedText, uploadedBatches, + toasts, attachedTraps: () => attached.size, runTimers: () => { const pending = timers.splice(0, timers.length); @@ -180,6 +184,36 @@ describe('Ctrl+V paste trap', () => { expect(h.pastedText).toEqual([]); }); + it('a paste mixing an image with a refused file uploads the image and says what it skipped', () => { + const h = loadPasteHarness(); + + h.firePaste({ text: 'shot.png', images: ['image/png', 'application/x-sh'] }); + + expect(h.uploadedBatches).toEqual([[{ type: 'image/png' }]]); + expect(h.toasts).toEqual(['1 file skipped: unsupported type']); + }); + + it('a paste whose only file is refused pastes its text instead, without a toast', () => { + const h = loadPasteHarness(); + + // Copying from a document can carry both: the text is what the user gets. + h.firePaste({ text: 'echo hi', images: ['application/x-sh'] }); + + expect(h.pastedText).toEqual(['echo hi']); + expect(h.uploadedBatches).toEqual([]); + expect(h.toasts).toEqual([]); + }); + + it('a paste of a refused file and no text says so instead of doing nothing', () => { + const h = loadPasteHarness(); + + h.firePaste({ images: ['application/x-sh'] }); + + expect(h.pastedText).toEqual([]); + expect(h.uploadedBatches).toEqual([]); + expect(h.toasts).toEqual(['Unsupported file type']); + }); + it('removes the trap and hands focus back after the paste it accepted', () => { const h = loadPasteHarness(); diff --git a/test/mobile-prompt-composer.test.ts b/test/mobile-prompt-composer.test.ts index 3d62b667..77576db1 100644 --- a/test/mobile-prompt-composer.test.ts +++ b/test/mobile-prompt-composer.test.ts @@ -13,6 +13,12 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; const accessorySource = readFileSync(resolve('src/web/public/keyboard-accessory.js'), 'utf8'); const appSource = readFileSync(resolve('src/web/public/app.js'), 'utf8'); +const imageInputSource = readFileSync(resolve('src/web/public/image-input.js'), 'utf8'); +// The real filter-and-toast helper both dialogs call (image-input.js), so these +// tests pin the shipped wording rather than a copy of it. +const { _admitPromptAttachments } = new Function( + `class CodemanApp {}\n${imageInputSource}\nreturn CodemanApp.prototype;` +)() as { _admitPromptAttachments: (files: unknown[], options?: { textPasted?: boolean }) => unknown[] }; type Timer = { callback: () => void; delay: number }; @@ -54,6 +60,7 @@ function loadComposer(sessionId = 'session-1') { ? 'document' : null, showToast: vi.fn(), + _admitPromptAttachments, }; const schedule = (callback: () => void, delay = 0) => { timers.push({ callback, delay }); @@ -566,7 +573,7 @@ describe('mobile prompt composer', () => { const pasteImage = () => { const item = { type: 'image/png', getAsFile: () => image }; const event = new document.defaultView!.Event('paste', { bubbles: true, cancelable: true }); - Object.defineProperty(event, 'clipboardData', { value: { items: [item] } }); + Object.defineProperty(event, 'clipboardData', { value: { items: [item], getData: () => '' } }); input.dispatchEvent(event); }; @@ -597,9 +604,37 @@ describe('mobile prompt composer', () => { fileInput.dispatchEvent(new document.defaultView!.Event('change', { bubbles: true })); expect(app._uploadAndInsertImages).toHaveBeenCalledWith([video, pdf], { insert: false }); + expect(app.showToast).toHaveBeenCalledWith('1 file skipped: unsupported type', 'info'); await vi.waitFor(() => expect(textarea(document).value).toContain('/tmp/image-one.png')); }); + it('says so when the Compose picker gets nothing it can upload', () => { + const { app, bar, document } = loadComposer(); + bar.composePrompt(); + const fileInput = document.querySelector('.paste-file-input') as HTMLInputElement; + const script = new document.defaultView!.File(['#!/bin/sh'], 'run.sh', { type: 'text/x-shellscript' }); + Object.defineProperty(fileInput, 'files', { configurable: true, value: [script] }); + + fileInput.dispatchEvent(new document.defaultView!.Event('change', { bubbles: true })); + + expect(app._uploadAndInsertImages).not.toHaveBeenCalled(); + expect(app.showToast).toHaveBeenCalledWith('Unsupported file type', 'error'); + }); + + it('leaves a text paste that also carries a refused file to the editor, without a toast', () => { + const { app, bar, document } = loadComposer(); + bar.composePrompt(); + const script = new document.defaultView!.File(['#!/bin/sh'], 'run.sh', { type: 'text/x-shellscript' }); + const event = new document.defaultView!.Event('paste', { bubbles: true, cancelable: true }); + Object.defineProperty(event, 'clipboardData', { + value: { items: [{ type: 'text/x-shellscript', getAsFile: () => script }], getData: () => 'echo hi' }, + }); + textarea(document).dispatchEvent(event); + expect(event.defaultPrevented).toBe(false); + expect(app._uploadAndInsertImages).not.toHaveBeenCalled(); + expect(app.showToast).not.toHaveBeenCalled(); + }); + 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']); @@ -628,6 +663,7 @@ describe('mobile prompt composer', () => { { type: 'text/plain', getAsFile: () => null }, { type: 'video/mp4', getAsFile: () => video }, ], + getData: () => '', }, }); input.dispatchEvent(event); @@ -724,6 +760,46 @@ describe('mobile shell Paste', () => { expect(document.querySelector('.paste-overlay')).toBeNull(); }); + it('uploads the admitted files of a mixed pick and says how many it skipped', () => { + const { app, document } = openShellPaste(); + const fileInput = document.querySelector('.paste-overlay .paste-file-input') as HTMLInputElement; + const image = new document.defaultView!.File(['image'], 'shot.png', { type: 'image/png' }); + const script = new document.defaultView!.File(['#!/bin/sh'], 'run.sh', { type: 'text/x-shellscript' }); + Object.defineProperty(fileInput, 'files', { configurable: true, value: [image, script] }); + + fileInput.dispatchEvent(new document.defaultView!.Event('change', { bubbles: true })); + + expect(app._uploadAndInsertImages).toHaveBeenCalledWith([image], { sessionId: 'session-1' }); + expect(app.showToast).toHaveBeenCalledWith('1 file skipped: unsupported type', 'info'); + }); + + it('keeps the dialog open and says so when the pick holds nothing it can upload', () => { + const { app, document } = openShellPaste(); + const fileInput = document.querySelector('.paste-overlay .paste-file-input') as HTMLInputElement; + const script = new document.defaultView!.File(['#!/bin/sh'], 'run.sh', { type: 'text/x-shellscript' }); + Object.defineProperty(fileInput, 'files', { configurable: true, value: [script] }); + + fileInput.dispatchEvent(new document.defaultView!.Event('change', { bubbles: true })); + + expect(app._uploadAndInsertImages).not.toHaveBeenCalled(); + expect(app.showToast).toHaveBeenCalledWith('Unsupported file type', 'error'); + expect(document.querySelector('.paste-overlay')).not.toBeNull(); + }); + + it('leaves a text paste that also carries a refused file to the textarea, without a toast', () => { + const { app, document, input } = openShellPaste(); + const script = new document.defaultView!.File(['#!/bin/sh'], 'run.sh', { type: 'text/x-shellscript' }); + const event = new document.defaultView!.Event('paste', { bubbles: true, cancelable: true }); + Object.defineProperty(event, 'clipboardData', { + value: { items: [{ type: 'text/x-shellscript', getAsFile: () => script }], getData: () => 'echo hi' }, + }); + input.dispatchEvent(event); + expect(event.defaultPrevented).toBe(false); + expect(app._uploadAndInsertImages).not.toHaveBeenCalled(); + expect(app.showToast).not.toHaveBeenCalled(); + expect(document.querySelector('.paste-overlay')).not.toBeNull(); + }); + it('sends nothing for an empty paste', () => { const { app, document, send, runTimers } = openShellPaste(); diff --git a/test/mocks/tile-grid-vm.ts b/test/mocks/tile-grid-vm.ts index 52c82cd6..bf629078 100644 --- a/test/mocks/tile-grid-vm.ts +++ b/test/mocks/tile-grid-vm.ts @@ -299,7 +299,7 @@ const context = vm.createContext({ }); vm.runInContext( `${read('constants.js')}\n${read('app.js')}\n${read('terminal-ui.js')}\n${read('terminal-split.js')}\n` + - `${read('tile-grid.js')}\n${read('mobile-overview.js')}\n` + + `${read('tile-grid.js')}\n${read('mobile-overview.js')}\n${read('image-input.js')}\n` + 'globalThis.__CodemanApp = CodemanApp;', context ); diff --git a/test/tile-grid-file-drop.test.ts b/test/tile-grid-file-drop.test.ts index c51b9220..21872e91 100644 --- a/test/tile-grid-file-drop.test.ts +++ b/test/tile-grid-file-drop.test.ts @@ -8,8 +8,9 @@ * phase): anywhere over it (a tile, an empty cell, a divider, its padding) * dragover and drop are cancelled, so the page never navigates, and a drop on * a tile uploads its files to THAT tile's session, through the same classifier - * (_promptAttachKind: images, videos and documents) and the same "Unsupported - * file type" toast as the single view. Tab and tile drags are the targets' own + * (_promptAttachKind: images, videos and documents) and the same toasts as the + * single view (_admitPromptAttachments: "Unsupported file type", or a count of + * the files skipped). Tab and tile drags are the targets' own * (_acceptTabDrops, capture phase) and stay untouched. * * Real code via the shared vm harness (test/mocks/tile-grid-vm.ts). Port: N/A. @@ -66,12 +67,13 @@ describe('a file dropped on a tile', () => { expect(app.activeSessionId).toBe('s-a'); }); - it('only the files the classifier admits are uploaded: a document and a video count, an unnamed text part does not', () => { + it('only the files the classifier admits are uploaded, and the skipped one is counted: a document and a video count, an unnamed text part does not', () => { const app = gridApp(); const pdf = { type: 'application/pdf', name: 'Q3 report.pdf' }; const mov = { type: 'video/quicktime', name: 'IMG_0001.MOV' }; section.dispatch('drop', fileEvent(tileEl('s-c'), [{ type: 'text/plain' }, PNG, pdf, mov])); expect(app._uploadAndInsertImages).toHaveBeenCalledWith([PNG, pdf, mov], { sessionId: 's-c' }); + expect(app.showToast).toHaveBeenCalledWith('1 file skipped: unsupported type', 'info'); }); it('a drop with nothing the classifier admits says so, as the single view does, and uploads nothing', () => { diff --git a/test/tile-grid-i18n.test.ts b/test/tile-grid-i18n.test.ts index 9c8e5dd5..597e8e74 100644 --- a/test/tile-grid-i18n.test.ts +++ b/test/tile-grid-i18n.test.ts @@ -246,6 +246,14 @@ async function exercise() { preventDefault: vi.fn(), }); harvestAll(app, 'file drop'); + // A drop mixing an admitted image with a refused file: the admitted one uploads. + app._uploadAndInsertImages = vi.fn(); + section.dispatch('drop', { + target: section.children.find((el) => el.dataset.sessionId === 's-1'), + dataTransfer: { types: ['Files'], files: [{ type: 'image/png' }, { type: 'application/pdf' }] }, + preventDefault: vi.fn(), + }); + harvestAll(app, 'mixed file drop'); // The toasts of a full grid, by the cap and by the window. app._joinTileGridFromRun('s-7'); @@ -349,6 +357,7 @@ describe('every tile grid string the code puts on screen translates to zh-CN', ( 'This group has no session to show as tiles', 'No sessions to show as tiles', 'Unsupported file type', + '1 file skipped: unsupported type', 'Split: unavailable while tiles are open', 'Tiles: show several sessions side by side (right-click for how many)', 'Tiles: back to a single session (right-click for how many tiles)', diff --git a/test/toolbar-attach.test.ts b/test/toolbar-attach.test.ts index 36318c9f..91c9a652 100644 --- a/test/toolbar-attach.test.ts +++ b/test/toolbar-attach.test.ts @@ -20,7 +20,7 @@ function loadToolbar(sessionId: string | null = 'session-1', localEcho = true, k '' + '' + '' + - '' + '
' ); const { window } = dom; // Stands in for the real bar: the production code reaches for an OPEN composer @@ -342,6 +342,49 @@ describe('phone toolbar Attach button', () => { expect(app.showToast).toHaveBeenCalledWith('Unsupported file type', 'error'); }); + it('uploads the admitted files of a mixed selection and says how many it skipped', async () => { + const { app, window, select } = loadToolbar(); + const photo = new window.File(['p'], 'photo.jpg', { type: 'image/jpeg' }); + const script = new window.File(['#!/bin/sh'], 'run.sh', { type: 'text/x-shellscript' }); + const pdf = new window.File(['%PDF-'], 'notes.pdf', { type: 'application/pdf' }); + select([photo, script, pdf]); + await vi.waitFor(() => expect(app._localEchoOverlay.appendText).toHaveBeenCalledTimes(1)); + expect(app._uploadAndInsertImages).toHaveBeenCalledWith([photo, pdf], { insert: false, sessionId: 'session-1' }); + expect(app.showToast).toHaveBeenCalledWith('1 file skipped: unsupported type', 'info'); + }); + + it('a file dropped on the terminal goes through the same filter and its toasts', () => { + const { app, window } = loadToolbar(); + const drop = (files: File[]) => { + const event = new window.Event('drop', { bubbles: true, cancelable: true }); + Object.defineProperty(event, 'dataTransfer', { value: { files, types: ['Files'] } }); + window.document.getElementById('terminalContainer')!.dispatchEvent(event); + }; + const photo = new window.File(['p'], 'photo.jpg', { type: 'image/jpeg' }); + const script = new window.File(['#!/bin/sh'], 'run.sh', { type: 'text/x-shellscript' }); + const page = new window.File(['

'], 'page.html', { type: 'text/html' }); + drop([photo, script, page]); + expect(app._uploadAndInsertImages).toHaveBeenCalledWith([photo]); + expect(app.showToast).toHaveBeenCalledWith('2 files skipped: unsupported type', 'info'); + app.showToast.mockClear(); + app._uploadAndInsertImages.mockClear(); + drop([script]); + expect(app._uploadAndInsertImages).not.toHaveBeenCalled(); + expect(app.showToast).toHaveBeenCalledWith('Unsupported file type', 'error'); + }); + + it('_admitPromptAttachments: silent when it admits everything, and quiet for a text paste with nothing admitted', () => { + const { app, window } = loadToolbar(); + const photo = new window.File(['p'], 'photo.jpg', { type: 'image/jpeg' }); + const script = new window.File(['#!/bin/sh'], 'run.sh', { type: 'text/x-shellscript' }); + expect(app._admitPromptAttachments([photo])).toEqual([photo]); + expect(app._admitPromptAttachments([script], { textPasted: true })).toEqual([]); + expect(app.showToast).not.toHaveBeenCalled(); + // Pasted text does not hide a partial refusal: the admitted file uploads, the text does not. + expect(app._admitPromptAttachments([photo, script], { textPasted: true })).toEqual([photo]); + expect(app.showToast).toHaveBeenCalledWith('1 file skipped: unsupported type', 'info'); + }); + it('classifies a pick by its name when the file manager supplied no MIME type', () => { const { app, window } = loadToolbar(); const kind = (name: string, type: string) => app._promptAttachKind(new window.File(['x'], name, { type })); From 0adce48f1ab9ec7074482279e163d8c406e45e9c Mon Sep 17 00:00:00 2001 From: Codeman maintainer Date: Sun, 18 Oct 2026 08:05:38 +0200 Subject: [PATCH 2/2] fix(#651): name _admitPromptAttachments() as the admission entry point in the docs CLAUDE.md (Prompt uploads) and architecture-invariants still named _promptAttachKind() as the filter every surface reads. That stays true, but the surfaces now admit through _admitPromptAttachments(), which is what says how many files a pick skipped. A new surface that filtered on the classifier directly would drop files silently. Co-Authored-By: Claude Opus 5.5 (1M context) --- CLAUDE.md | 2 +- docs/architecture-invariants.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 207548c2..e66fdc64 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -215,7 +215,7 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph **Attachments** (live external document references; all wiring in `file-routes.ts`): a **registry** maps a stable `attachmentId` to a realpath-resolved, extension-allowlisted absolute path, so browser requests never carry arbitrary absolute paths. ⚠️ The **magic-link scanner** (`codeman://attach?...` in terminal output) is **prompt-injectable**, so its scan path is force-confined to the session workspace; a hostile prompt could otherwise exfiltrate arbitrary host files over SSE. The security gate is an extension **allowlist**, not a blocklist. `document-conversion-limiter.ts` caps converter spawns globally: without it, N large docs detected at once fork N multi-minute processes, which is a resource-exhaustion vector. → [architecture-invariants#attachments](docs/architecture-invariants.md#attachments) -**Prompt uploads** (`POST /api/sessions/:id/paste-image`: clipboard paste, drop, the Compose Attach key and the phone toolbar's Attach button; client wiring in `image-input.js` and `keyboard-accessory.js`): a pick is written to `/.codeman-uploads/` and its path lands on the prompt as unsent text. The folder is IN the workspace because that is the only path identical for a local and a container agent, FLAT because `/.codeman/` would BE the data dir when the workspace is the home directory, and self-ignoring through a `.gitignore` of `*` written ONCE with `wx` (a file already there is the user's and is never overwritten; the user's own ignore file is never touched). ⚠️ The directory names live ONLY in `paste-image-gc.ts` (`UPLOAD_DIR_NAMES`): the pre-move `.claude-images/` receives nothing but stays readable for one release, so the GC sweep and the delete cleanup (through `uploadDirs()`) and the image watcher's ignore filter (reading the names) cover BOTH; retire the legacy entry there, never by hand elsewhere. ⚠️ `uploadDirs()` is async and probes the workspace BOUNDED first (`probePathKind()`, the #516 rule: an `unknown` path is skipped, never touched; the sweep keeps the stall cap and the delete passes `pastCap`), then returns only REAL directories (lstat, so a planted symlink is never one), none that is or contains `getDataDir()` (one strictly inside it only holds uploads and is listed), and none for a remote session, since both consumers delete; the check is made at list time, and the same-user race after it is accepted. Images are buffered in memory under `MAX_PASTE_IMAGE_BYTES` (50MB) after a client-side canvas re-encode; a DOCUMENT (pdf, docx, pptx, xlsx, zip and the text family txt/md/csv/tsv/json/log/yaml/yml/xml; the curated `ALLOWED_DOCUMENT_EXTS`, never `SUPPORTED_ATTACHMENT_EXTENSIONS` or `EDITABLE_EXTENSIONS`, which carry sh/py/js/html) is admitted by its name's extension alone, buffered like an image, judged on its bytes by the same fail-closed `imageMagicMatchesExt` (`%PDF-`, `PK\x03\x04` for the OOXML trio and zip, UTF-8 without a NUL byte for text: a SIGNATURE or ENCODING check, never a structure or content check, so generic zip bytes pass as `.docx` and a script renamed `.txt` is text; the sh/py/js/html exclusion is a SCOPE choice by extension, keeping the server from storing what it would serve or run, not a content filter: the agent can already write any of those) and saved as `paste---` with a sanitized stem (`[A-Za-z0-9._-]`, 40 chars, a wholly non-ASCII name leaves no stem; the `paste-` prefix is what the GC matches). A VIDEO is never buffered: `?kind=video` streams it to disk under `MAX_PASTE_VIDEO_BYTES` (1GB, env `CODEMAN_MAX_PASTE_VIDEO_BYTES`), judges the container on the first 12 bytes as they arrive (ISO BMFF / QuickTime atom, EBML for webm, Ogg for ogv; a mislabeled body is drained unwritten from there, never parked on disk first) and unlinks on any failure, a dead transfer included. ⚠️ The flag is the ONLY way onto that path and admits video types alone (the registry's `VIDEO_ATTACHMENT_EXTENSIONS`, single-sourced: mp4/webm/mov/m4v/ogv, so an uploaded video is one the file viewer can also play; 3gp/mkv are out, since a phone's camera roll produces neither and neither previews), so an image can never ride the larger cap through memory; without it the route is byte-identical to the image-only version. Busboy takes its size limit before the part is seen, which is why the choice rides the URL and not the file. ⚠️ A video draws on its OWN per-(IP, session) rate bucket (`consumeVideoToken`, 3/min) beside the image bucket (`consumePasteToken`, 30/min): at 1GB each, 30/min would be 30GB of disk a minute, so the lower rate bounds it to 3GB; the image path keeps the 30/min bucket and is unchanged without the flag. A remote (SSH) session is refused (400) before any disk touch: its `workingDir` is the remote path, so the file would land on THIS host where the agent cannot read it (Docker is fine, the workspace is bind-mounted at the same absolute path). ⚠️ A live session's workspace can still sit on a network mount that stopped answering, so the route probes it BOUNDED (`probePathKind(session.workingDir, { pastCap: true })`, the #516 rule) before the first disk touch and refuses an `unknown` mount through `describeUnknownPath()` (OPERATION_FAILED), the way session create does, so a mount already unreachable cannot freeze a libuv worker on every upload (a mount that dies after the probe is the same residual race create accepts). ⚠️ Dispatch mirrors the client: an `image/*` MIME takes the IMAGE path whatever the name says (a GIF named `animation.txt` stays a GIF), a document extension takes the document branch, and anything else takes the image path exactly as before, where the HEIC sniff still recovers a HEIC under a generic name; a foreign extension with no image MIME gets its `Unsupported file type` 400 only AFTER that sniff fails, never before, so nothing the image path ever accepted by its bytes is refused. The text-mismatch log line names the failure (`nul-byte`/`not-utf8`) and never prints the bytes, since the head of a text file is user content. ⚠️ The client classifies a pick through ONE helper, `_promptAttachKind()` (MIME type first, then the name's extension, because some Android file managers hand over a File with none; returns `image`, `video` or `document`); every pick and paste filter (Attach, drop, the tile grid's drop in `tile-grid.js`, the Compose and paste-dialog pickers, the clipboard handlers), the flag and the re-encode gate read it, never `file.type` directly. ⚠️ A pick that lands while the tile grid owns the terminal goes to the PTY, never the main terminal's overlay: that terminal is parked and hidden then, and a tile's Enter cannot flush it (`_tilesOwnTerminal()` in the `_attachFromToolbar` guard). Tests: `test/toolbar-attach.test.ts`, `test/mobile-prompt-composer.test.ts`, `test/paste-image-gc.test.ts`, `test/paste-image-dir-shared.test.ts`, the paste-image block of `test/routes/session-routes.test.ts`. → [architecture-invariants#buffers-uploads-and-terminal-history](docs/architecture-invariants.md#buffers-uploads-and-terminal-history) +**Prompt uploads** (`POST /api/sessions/:id/paste-image`: clipboard paste, drop, the Compose Attach key and the phone toolbar's Attach button; client wiring in `image-input.js` and `keyboard-accessory.js`): a pick is written to `/.codeman-uploads/` and its path lands on the prompt as unsent text. The folder is IN the workspace because that is the only path identical for a local and a container agent, FLAT because `/.codeman/` would BE the data dir when the workspace is the home directory, and self-ignoring through a `.gitignore` of `*` written ONCE with `wx` (a file already there is the user's and is never overwritten; the user's own ignore file is never touched). ⚠️ The directory names live ONLY in `paste-image-gc.ts` (`UPLOAD_DIR_NAMES`): the pre-move `.claude-images/` receives nothing but stays readable for one release, so the GC sweep and the delete cleanup (through `uploadDirs()`) and the image watcher's ignore filter (reading the names) cover BOTH; retire the legacy entry there, never by hand elsewhere. ⚠️ `uploadDirs()` is async and probes the workspace BOUNDED first (`probePathKind()`, the #516 rule: an `unknown` path is skipped, never touched; the sweep keeps the stall cap and the delete passes `pastCap`), then returns only REAL directories (lstat, so a planted symlink is never one), none that is or contains `getDataDir()` (one strictly inside it only holds uploads and is listed), and none for a remote session, since both consumers delete; the check is made at list time, and the same-user race after it is accepted. Images are buffered in memory under `MAX_PASTE_IMAGE_BYTES` (50MB) after a client-side canvas re-encode; a DOCUMENT (pdf, docx, pptx, xlsx, zip and the text family txt/md/csv/tsv/json/log/yaml/yml/xml; the curated `ALLOWED_DOCUMENT_EXTS`, never `SUPPORTED_ATTACHMENT_EXTENSIONS` or `EDITABLE_EXTENSIONS`, which carry sh/py/js/html) is admitted by its name's extension alone, buffered like an image, judged on its bytes by the same fail-closed `imageMagicMatchesExt` (`%PDF-`, `PK\x03\x04` for the OOXML trio and zip, UTF-8 without a NUL byte for text: a SIGNATURE or ENCODING check, never a structure or content check, so generic zip bytes pass as `.docx` and a script renamed `.txt` is text; the sh/py/js/html exclusion is a SCOPE choice by extension, keeping the server from storing what it would serve or run, not a content filter: the agent can already write any of those) and saved as `paste---` with a sanitized stem (`[A-Za-z0-9._-]`, 40 chars, a wholly non-ASCII name leaves no stem; the `paste-` prefix is what the GC matches). A VIDEO is never buffered: `?kind=video` streams it to disk under `MAX_PASTE_VIDEO_BYTES` (1GB, env `CODEMAN_MAX_PASTE_VIDEO_BYTES`), judges the container on the first 12 bytes as they arrive (ISO BMFF / QuickTime atom, EBML for webm, Ogg for ogv; a mislabeled body is drained unwritten from there, never parked on disk first) and unlinks on any failure, a dead transfer included. ⚠️ The flag is the ONLY way onto that path and admits video types alone (the registry's `VIDEO_ATTACHMENT_EXTENSIONS`, single-sourced: mp4/webm/mov/m4v/ogv, so an uploaded video is one the file viewer can also play; 3gp/mkv are out, since a phone's camera roll produces neither and neither previews), so an image can never ride the larger cap through memory; without it the route is byte-identical to the image-only version. Busboy takes its size limit before the part is seen, which is why the choice rides the URL and not the file. ⚠️ A video draws on its OWN per-(IP, session) rate bucket (`consumeVideoToken`, 3/min) beside the image bucket (`consumePasteToken`, 30/min): at 1GB each, 30/min would be 30GB of disk a minute, so the lower rate bounds it to 3GB; the image path keeps the 30/min bucket and is unchanged without the flag. A remote (SSH) session is refused (400) before any disk touch: its `workingDir` is the remote path, so the file would land on THIS host where the agent cannot read it (Docker is fine, the workspace is bind-mounted at the same absolute path). ⚠️ A live session's workspace can still sit on a network mount that stopped answering, so the route probes it BOUNDED (`probePathKind(session.workingDir, { pastCap: true })`, the #516 rule) before the first disk touch and refuses an `unknown` mount through `describeUnknownPath()` (OPERATION_FAILED), the way session create does, so a mount already unreachable cannot freeze a libuv worker on every upload (a mount that dies after the probe is the same residual race create accepts). ⚠️ Dispatch mirrors the client: an `image/*` MIME takes the IMAGE path whatever the name says (a GIF named `animation.txt` stays a GIF), a document extension takes the document branch, and anything else takes the image path exactly as before, where the HEIC sniff still recovers a HEIC under a generic name; a foreign extension with no image MIME gets its `Unsupported file type` 400 only AFTER that sniff fails, never before, so nothing the image path ever accepted by its bytes is refused. The text-mismatch log line names the failure (`nul-byte`/`not-utf8`) and never prints the bytes, since the head of a text file is user content. ⚠️ The client classifies a pick through ONE helper, `_promptAttachKind()` (MIME type first, then the name's extension, because some Android file managers hand over a File with none; returns `image`, `video` or `document`); every pick and paste filter (Attach, drop, the tile grid's drop in `tile-grid.js`, the Compose and paste-dialog pickers, the clipboard handlers), the flag and the re-encode gate read it, never `file.type` directly. Those surfaces ADMIT through `_admitPromptAttachments()` (image-input.js), which calls it and toasts what a pick skipped (how many files of a mixed pick, `Unsupported file type` when none pass, nothing for a text paste); a new surface admits through that helper too, never by filtering on `_promptAttachKind()` itself, or it loses the toasts. ⚠️ A pick that lands while the tile grid owns the terminal goes to the PTY, never the main terminal's overlay: that terminal is parked and hidden then, and a tile's Enter cannot flush it (`_tilesOwnTerminal()` in the `_attachFromToolbar` guard). Tests: `test/toolbar-attach.test.ts`, `test/mobile-prompt-composer.test.ts`, `test/paste-image-gc.test.ts`, `test/paste-image-dir-shared.test.ts`, the paste-image block of `test/routes/session-routes.test.ts`. → [architecture-invariants#buffers-uploads-and-terminal-history](docs/architecture-invariants.md#buffers-uploads-and-terminal-history) **File-path links (terminal + chat)**: a path an agent prints is clickable on BOTH surfaces and opens the file-preview overlay. ⚠️ ONE pattern (`FILE_PATH_LINK_PATTERN` / `absoluteFilePathPattern()` in constants.js) feeds the xterm link provider AND `_linkifyFilePaths()`, a fresh instance per call (`lastIndex`). Both call `findFilePathLinks()`, which adds `~/`, relative and folder paths without overlapping a rooted match or a URL; a relative path resolves against `workingDir` (`resolveLinkedFilePath`), `~/` only server-side on the session's host (attachment route), and `terminalPathLine()` joins a path a program hard-wrapped short of the edge. ⚠️ A folder link opens the **folder grid**: `POST /api/sessions/:id/folders` guards + GRANTS the folder (`folder-listing.ts`, own registry, never the 200-capped attachment registry), files come by bare name via `…/folders/:folderId/files/:name/*` through `resolveServableAttachmentPath` plus the same extension/regular-file gate on the symlink's resolved target, so no browser request carries a path; `~/` is refused in a Docker case (the container's home is not this host's). The chat linkifier walks TEXT NODES with DOM APIs, never rebuilds sanitized markup as a string. ⚠️ An out-of-workspace path goes through the ATTACHMENT routes (`POST /api/sessions/:id/attachments` with `notify: false`), never by widening `file-content`/`file-raw` or `file-stream-manager`'s `tail -f` allowlist. ⚠️ `TEXT_ATTACHMENT_EXTENSIONS` IS `EDITABLE_EXTENSIONS` (never a second list), and widening READ must never widen RUN: `html`/`htm`/`svg` stay download-only, other text is inert `text/plain`+`nosniff`. ⚠️ The ONE place HTML renders is `/html-view//...` (`html-view-routes.ts`, `html-view-capabilities.ts`): `POST /api/sessions/:id/html-view` mints a capability for the file's own directory, every response carries `Content-Security-Policy: sandbox` WITHOUT `allow-same-origin` (opaque origin even as a top-level tab, so the page can never read Codeman or drive its API) and with NO fetch directive (open network, owner decision 2026-10-13: never restrict it or add `allow-same-origin` without the owner), and only non-dot, asset-allowlisted files under that directory are served (GET/HEAD only; capabilities revoked with the web-tab ones on logout). That tree is served recursively, so minting is refused for a hidden directory or a broad root (`isBroadHtmlViewRoot`); the preview renders by default and the **Page** pill (or a refused mint) falls through to the source view and its Edit button. Never serve HTML same-origin anywhere else. Tests: `test/routes/html-view-routes.test.ts`. Media extensions are single-sourced in `attachment-registry.ts`. ⚠️ **XLSX previews parse in the BROWSER**, never on the server; the admission limits that keep a hostile workbook from freezing the worker live in `src/web/CLAUDE.md` (Spreadsheet preview). → [architecture-invariants#file-path-links-terminal--response-viewer](docs/architecture-invariants.md#file-path-links-terminal--response-viewer) diff --git a/docs/architecture-invariants.md b/docs/architecture-invariants.md index 896e8d73..74a6a14b 100644 --- a/docs/architecture-invariants.md +++ b/docs/architecture-invariants.md @@ -1124,7 +1124,7 @@ Tests: `test/mobile-prompt-composer.test.ts` (in the CI gate, deliberately not u ### Buffers, uploads, and terminal history -Target: 20 sessions, 50 agent windows at 60fps. Limits in `src/config/`: terminal 32MB (see below), text 1MB, messages 1000, max agents 500, max sessions 50, max SSE clients 100. **Terminal history** (`src/config/terminal-history.ts`, COD-80): tmux history-limit 100k lines, PTY buffer 32MB max / 24MB trim (env `CODEMAN_MAX_TERMINAL_BUFFER`/`CODEMAN_TRIM_TERMINAL_TO`; the env-derived trim is clamped ≤75% of max — trim ≥ max would disable `BufferAccumulator` trimming entirely = unbounded memory); browser xterm scrollback stays a separate hardcoded 50k (`DEFAULT_SCROLLBACK` in constants.js — 100k/tab is a mobile-memory hazard). tmux <3.7 allocates history at pane creation, so `createSession()` sets the global default in the same command queue immediately before `new-session`; tmux 3.7+ instead creates the session and targets only that pane, because changing the global option can resize and trim unrelated live panes. A settings change resizes tracked panes only on 3.7+ and otherwise affects future panes; no version can recover lines already evicted. Settings keys `terminalScrollbackLines`/`terminalBufferMaxBytes`/`terminalBufferTrimBytes` are schema-validated but inert (only `tmuxHistoryLimit` is wired); `buffer-limits.ts` re-exports the defaults. Text/message limits are env-overridable too (`CODEMAN_MAX_TEXT_OUTPUT`/`CODEMAN_TRIM_TEXT_TO`/`CODEMAN_MAX_MESSAGES`). **Image upload** (`image-input.js` / `config/buffer-limits.ts`): up to `_maxBatchImages` 20 images/batch (bounded concurrency 3), per-file `MAX_PASTE_IMAGE_BYTES` 50MB (env `CODEMAN_MAX_PASTE_IMAGE_BYTES`); the mobile camera-roll picker auto-downscales to fit before upload. A camera-roll VIDEO (`?kind=video` on the same route, set by the client from the file's MIME type) is never buffered: it streams to disk under `MAX_PASTE_VIDEO_BYTES` 1GB (env `CODEMAN_MAX_PASTE_VIDEO_BYTES`), sniffed from the stream's first bytes (ISO BMFF / QuickTime atom, EBML for webm, Ogg for ogv) and unlinked on any failure. The client keys the flag on the MIME type, falling back to the name's extension because some Android file managers hand over a File with none; that classifier (`_promptAttachKind()`, image-input.js) is the ONE filter every pick and paste surface reads: Attach, drop, the Compose and paste-dialog pickers and the clipboard handlers. ⚠️ The flag is the ONLY way onto that path and admits video types alone (the registry's `VIDEO_ATTACHMENT_EXTENSIONS`, single-sourced so an uploaded video also previews: mp4/webm/mov/m4v/ogv; 3gp/mkv are out), so an image can never ride the larger cap through memory; without it the route is byte-identical to the image-only version. A DOCUMENT (the office formats pdf/docx/pptx/xlsx/zip plus the text family txt/md/csv/tsv/json/log/yaml/yml/xml, the curated `ALLOWED_DOCUMENT_EXTS`, never `SUPPORTED_ATTACHMENT_EXTENSIONS` or `EDITABLE_EXTENSIONS`) is admitted by its name's extension and buffered like an image, judged on its bytes by the same fail-closed `imageMagicMatchesExt` (a magic-number signature, or UTF-8 without a NUL byte for the text family) and saved under a sanitized `[A-Za-z0-9._-]` stem of its name; sh/py/js/html are a deliberate SCOPE exclusion by extension (the server declines to store what `file-raw` would serve or an agent could run), not a content filter, since the agent can already write any of them. `_promptAttachKind()` 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 `Unsupported file type` only after the image sniff fails, so nothing the image path accepted by its bytes is lost. **HEIC paste uploads** (#151): converted server-side to JPEG in a `worker_threads` worker (`web/heic-jpeg-worker.ts`, resourceLimits + 30s timeout) gated by `runWithConversionLimit()`; detection is magic-byte based (covers Android/MIUI HEIFs mislabeled as JPEG); headers declaring > 64MP are rejected 415 BEFORE decode (decompression-bomb guard). Deps: `heic-decode` + `jpeg-js`. Use `LRUMap` for bounded caches, `StaleExpirationMap` for TTL cleanup. Anti-flicker pipeline: `docs/terminal-anti-flicker.md`. +Target: 20 sessions, 50 agent windows at 60fps. Limits in `src/config/`: terminal 32MB (see below), text 1MB, messages 1000, max agents 500, max sessions 50, max SSE clients 100. **Terminal history** (`src/config/terminal-history.ts`, COD-80): tmux history-limit 100k lines, PTY buffer 32MB max / 24MB trim (env `CODEMAN_MAX_TERMINAL_BUFFER`/`CODEMAN_TRIM_TERMINAL_TO`; the env-derived trim is clamped ≤75% of max — trim ≥ max would disable `BufferAccumulator` trimming entirely = unbounded memory); browser xterm scrollback stays a separate hardcoded 50k (`DEFAULT_SCROLLBACK` in constants.js — 100k/tab is a mobile-memory hazard). tmux <3.7 allocates history at pane creation, so `createSession()` sets the global default in the same command queue immediately before `new-session`; tmux 3.7+ instead creates the session and targets only that pane, because changing the global option can resize and trim unrelated live panes. A settings change resizes tracked panes only on 3.7+ and otherwise affects future panes; no version can recover lines already evicted. Settings keys `terminalScrollbackLines`/`terminalBufferMaxBytes`/`terminalBufferTrimBytes` are schema-validated but inert (only `tmuxHistoryLimit` is wired); `buffer-limits.ts` re-exports the defaults. Text/message limits are env-overridable too (`CODEMAN_MAX_TEXT_OUTPUT`/`CODEMAN_TRIM_TEXT_TO`/`CODEMAN_MAX_MESSAGES`). **Image upload** (`image-input.js` / `config/buffer-limits.ts`): up to `_maxBatchImages` 20 images/batch (bounded concurrency 3), per-file `MAX_PASTE_IMAGE_BYTES` 50MB (env `CODEMAN_MAX_PASTE_IMAGE_BYTES`); the mobile camera-roll picker auto-downscales to fit before upload. A camera-roll VIDEO (`?kind=video` on the same route, set by the client from the file's MIME type) is never buffered: it streams to disk under `MAX_PASTE_VIDEO_BYTES` 1GB (env `CODEMAN_MAX_PASTE_VIDEO_BYTES`), sniffed from the stream's first bytes (ISO BMFF / QuickTime atom, EBML for webm, Ogg for ogv) and unlinked on any failure. The client keys the flag on the MIME type, falling back to the name's extension because some Android file managers hand over a File with none; that classifier (`_promptAttachKind()`, image-input.js) is the ONE filter every pick and paste surface reads: Attach, drop, the Compose and paste-dialog pickers and the clipboard handlers. They reach it through `_admitPromptAttachments()` (same file), which also toasts what a pick skipped: the count of a mixed pick's refused files, `Unsupported file type` when none pass, and nothing for a text paste. A new surface admits through that helper, never by filtering on `_promptAttachKind()` directly, or it silently drops files with no word to the user. ⚠️ The flag is the ONLY way onto that path and admits video types alone (the registry's `VIDEO_ATTACHMENT_EXTENSIONS`, single-sourced so an uploaded video also previews: mp4/webm/mov/m4v/ogv; 3gp/mkv are out), so an image can never ride the larger cap through memory; without it the route is byte-identical to the image-only version. A DOCUMENT (the office formats pdf/docx/pptx/xlsx/zip plus the text family txt/md/csv/tsv/json/log/yaml/yml/xml, the curated `ALLOWED_DOCUMENT_EXTS`, never `SUPPORTED_ATTACHMENT_EXTENSIONS` or `EDITABLE_EXTENSIONS`) is admitted by its name's extension and buffered like an image, judged on its bytes by the same fail-closed `imageMagicMatchesExt` (a magic-number signature, or UTF-8 without a NUL byte for the text family) and saved under a sanitized `[A-Za-z0-9._-]` stem of its name; sh/py/js/html are a deliberate SCOPE exclusion by extension (the server declines to store what `file-raw` would serve or an agent could run), not a content filter, since the agent can already write any of them. `_promptAttachKind()` 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 `Unsupported file type` only after the image sniff fails, so nothing the image path accepted by its bytes is lost. **HEIC paste uploads** (#151): converted server-side to JPEG in a `worker_threads` worker (`web/heic-jpeg-worker.ts`, resourceLimits + 30s timeout) gated by `runWithConversionLimit()`; detection is magic-byte based (covers Android/MIUI HEIFs mislabeled as JPEG); headers declaring > 64MP are rejected 415 BEFORE decode (decompression-bomb guard). Deps: `heic-decode` + `jpeg-js`. Use `LRUMap` for bounded caches, `StaleExpirationMap` for TTL cleanup. Anti-flicker pipeline: `docs/terminal-anti-flicker.md`. **Prompt uploads live in `/.codeman-uploads/`** (`POST /api/sessions/:id/paste-image`; the names live ONLY in `src/web/paste-image-gc.ts`): IN the workspace because that is the only path that resolves identically for a local agent and a container (only the workspace is bind-mounted, at the same absolute path; `~/.codeman` is not); hidden so it stays out of `git status` and the agent's view of the repository; FLAT because a nested `/.codeman/` IS the data dir (`join(homedir(), '.codeman')`, `src/config/instance.ts`) when the workspace is the home directory, which nothing refuses; and self-ignoring through a `.gitignore` of `*` written once with `wx`, so a file already there is the user's and stays. The pre-move `.claude-images/` receives nothing but stays readable for one release: `UPLOAD_DIR_NAMES` lists both, the hourly sweep and the delete cleanup in `cleanupSession()` go through `uploadDirs()`, and the image watcher's ignore filter reads the names. ⚠️ `uploadDirs()` is async: the working directory is a user-chosen path, so it is probed BOUNDED first (`probePathKind()`, the #516 rule; `unknown` is skipped and never touched, the hourly sweep keeps the stall cap and `cleanupSession()` passes `pastCap`, since it acts on one path at the user's request), and the lstat and realpath after it are `fs.promises` calls, never sync, because the sweep runs 30 s after boot and hourly over every live session and a linked case on a dead mount would otherwise freeze the whole server. It returns only REAL directories (lstat at the leaf, so a planted `.codeman-uploads -> /other-case/.codeman-uploads` is never listed: `readdir` follows a link to a directory, and the sweep would otherwise age out the other case's uploads through it), none that is or contains `getDataDir()` (contrived, `CODEMAN_INSTANCE=uploads` makes `~/.codeman-uploads` the data dir of a home workspace and `CODEMAN_DATA_DIR` can point inside one, but it is one check next to a recursive delete; one strictly INSIDE the data dir is listed, since it only ever holds uploads and a workspace like the `~/.codeman/app` that `install.sh` clones would otherwise never have its uploads collected), and nothing for a remote (SSH) session, whose `workingDir` is the remote path and would otherwise name a same-named LOCAL directory for the sweep and the delete. The check is made when the directories are listed: a same-user process that swaps a listed directory for a link during the sweep's awaits is accepted (Node has no `openat()`, and that actor already writes anywhere this process can). The route refuses a remote (SSH) session with 400 before any disk touch: its `workingDir` is the remote path, so the file would land on THIS host where the agent cannot read it. ⚠️ And because a live session's workspace can go onto a network mount that later stops answering, the route probes `session.workingDir` BOUNDED (`probePathKind(..., { pastCap: true })`) before the first disk touch, refusing an `unknown` mount through `describeUnknownPath()` (OPERATION_FAILED) exactly as `POST /api/sessions` does, so a mount already unreachable cannot freeze a libuv worker on every upload (`ensureUploadDir()`'s own lstat/mkdir then start on a reachable path; a mount that dies between the probe and that write is the same residual race `POST /api/sessions` accepts, since a pre-check cannot bound the write that follows it). Tests: `test/paste-image-gc.test.ts`, `test/paste-image-dir-shared.test.ts`, the paste-image block of `test/routes/session-routes.test.ts`, `test/routes/session-create-unreachable-path.test.ts`.