From 7e8ed90e2d9e273d5377e441be376232475f4c06 Mon Sep 17 00:00:00 2001 From: JD Date: Fri, 16 Oct 2026 10:40:37 -0400 Subject: [PATCH 1/3] test(uploads): pin the browser's copies of the document list to ALLOWED_DOCUMENT_EXTS The browser cannot import the server's document allowlist, so the classifier, the three pickers' accept lists and the two file-path link patterns each carry a hand-kept copy. A missed copy fails silently: a file greyed out in the picker, an upload dropped client-side, or a pasted path that is no longer a link. Hoist the set to module scope so the test reads the shipped list. --- CLAUDE.md | 2 +- src/web/routes/session-routes.ts | 16 +-- test/upload-document-list-parity.test.ts | 127 +++++++++++++++++++++++ 3 files changed, 138 insertions(+), 7 deletions(-) create mode 100644 test/upload-document-list-parity.test.ts diff --git a/CLAUDE.md b/CLAUDE.md index 207548c2..c695fdf4 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. ⚠️ 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`, `test/upload-document-list-parity.test.ts` (the browser's copies of `ALLOWED_DOCUMENT_EXTS`: `_promptAttachKind()`, the three pickers' `accept=` and the two file-path link patterns), 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/src/web/routes/session-routes.ts b/src/web/routes/session-routes.ts index d0ebc07e..e3980224 100644 --- a/src/web/routes/session-routes.ts +++ b/src/web/routes/session-routes.ts @@ -330,6 +330,16 @@ export function stripInkRedrawBloat(buffer: string): string { */ const TEXT_UPLOAD_EXTS = new Set(['.txt', '.md', '.csv', '.tsv', '.json', '.log', '.yaml', '.yml', '.xml']); +/** + * Documents a user hands an agent to read: the office formats plus the text + * family. Curated on purpose, never SUPPORTED_ATTACHMENT_EXTENSIONS or + * EDITABLE_EXTENSIONS, which carry sh/py/js/html; a prompt upload is a thing + * to read, not a thing to run or serve. Each is judged on its bytes by + * imageMagicMatchesExt (signature, or UTF-8 without NUL for text). The browser + * keeps its own copies (test/upload-document-list-parity.test.ts pins them). + */ +export const ALLOWED_DOCUMENT_EXTS = new Set(['.pdf', '.docx', '.pptx', '.xlsx', '.zip', ...TEXT_UPLOAD_EXTS]); + /** * Validate upload bytes against a declared extension. Sniffs the first ~12 bytes * for a known magic-number signature (a text file is instead required to be @@ -5447,12 +5457,6 @@ export function registerSessionRoutes( // so an uploaded video is a type that previews. 3gp/mkv are deliberately out: they // would upload but not preview, and a phone's camera roll produces neither. const ALLOWED_VIDEO_EXTS = new Set([...VIDEO_ATTACHMENT_EXTENSIONS].map((e) => `.${e}`)); - // Documents a user hands an agent to read: the office formats plus the text - // family. Curated on purpose, never SUPPORTED_ATTACHMENT_EXTENSIONS or - // EDITABLE_EXTENSIONS, which carry sh/py/js/html; a prompt upload is a thing - // to read, not a thing to run or serve. Each is judged on its bytes by - // imageMagicMatchesExt (signature, or UTF-8 without NUL for text). - const ALLOWED_DOCUMENT_EXTS = new Set(['.pdf', '.docx', '.pptx', '.xlsx', '.zip', ...TEXT_UPLOAD_EXTS]); const VIDEO_MIME_EXT: Record = { mp4: '.mp4', quicktime: '.mov', diff --git a/test/upload-document-list-parity.test.ts b/test/upload-document-list-parity.test.ts new file mode 100644 index 00000000..05aa23f8 --- /dev/null +++ b/test/upload-document-list-parity.test.ts @@ -0,0 +1,127 @@ +/** + * @fileoverview Prompt-upload document list parity: server ⇄ browser copies. + * + * The server admits a document upload by extension through + * `ALLOWED_DOCUMENT_EXTS` (session-routes.ts). The browser cannot import it, so + * the list is copied by hand into: + * + * - `_promptAttachKind()` (image-input.js), the one classifier every pick, + * drop and paste reads: an extension missing there is dropped client-side + * with "Unsupported file type", an extra one uploads only to be refused. + * - the `accept=` of the toolbar Attach input (index.html `#attachFileInput`) + * and of the two Compose / paste-dialog pickers (keyboard-accessory.js + * `.paste-file-input`): an extension missing there is greyed out in the + * picker, so the user cannot pick the file at all. + * - the two file-path link patterns (constants.js): an uploaded document's + * path lands on the prompt and comes back in the agent's output, and an + * extension missing there leaves that path as plain text. tsv and zip were + * added there by hand once already. + * + * Same idea as test/media-extension-parity.test.ts: the server set is imported, + * the browser copies are read from the shipped files. The classifier and the + * link patterns are run, not just read. No port needed. + */ +import { readFileSync } from 'node:fs'; +import { resolve } from 'node:path'; +import vm from 'node:vm'; +import { JSDOM } from 'jsdom'; +import { describe, expect, it } from 'vitest'; +import { ALLOWED_DOCUMENT_EXTS } from '../src/web/routes/session-routes.js'; + +const PUBLIC = resolve(import.meta.dirname, '../src/web/public'); +const read = (f: string) => readFileSync(resolve(PUBLIC, f), 'utf8'); + +/** Server extensions without the dot, e.g. `pdf`. */ +const DOCUMENT_EXTS = [...ALLOWED_DOCUMENT_EXTS].map((e) => e.replace(/^\./, '')).sort(); + +/** The document extensions an `accept=` value lists (the `image/*` and `video/*` entries dropped). */ +function acceptDocumentExts(accept: string): string[] { + return accept + .split(',') + .map((t) => t.trim()) + .filter((t) => t.startsWith('.')) + .map((t) => t.slice(1)) + .sort(); +} + +type AttachKind = (file: { name: string; type: string }) => string | null; + +function loadPromptAttachKind(): { kind: AttachKind; method: string } { + const source = read('image-input.js'); + const context = vm.createContext({}); + vm.runInContext('class CodemanApp {}', context); + vm.runInContext(source, context, { filename: 'image-input.js' }); + const app = new (vm.runInContext('CodemanApp', context) as new () => { _promptAttachKind: AttachKind })(); + // The live method's own text, comments stripped, so a stale copy elsewhere cannot stand in for it. + const method = app._promptAttachKind.toString().replace(/\/\*[\s\S]*?\*\/|\/\/.*$/gm, ''); + return { kind: (file) => app._promptAttachKind(file), method }; +} + +describe('prompt-upload document list parity (ALLOWED_DOCUMENT_EXTS ⇄ browser copies)', () => { + it('image-input.js _promptAttachKind classifies every server document extension as a document', () => { + const { kind } = loadPromptAttachKind(); + // No MIME: the extension alone decides, as on the server. + const missing = DOCUMENT_EXTS.filter((ext) => kind({ name: `report.${ext}`, type: '' }) !== 'document'); + expect(missing, `image-input.js _promptAttachKind() does not classify as document: ${missing.join(', ')}`).toEqual( + [] + ); + }); + + it('image-input.js _promptAttachKind lists no document extension the server refuses', () => { + const { kind, method } = loadPromptAttachKind(); + const found = [...method.matchAll(/if \(\[([^\]]+)\]\.includes\(ext\)\) \{\s*return 'document';/g)]; + expect(found.length, 'expected one document extension list in image-input.js _promptAttachKind()').toBe(1); + const clientExts = [...found[0][1].matchAll(/'([^']+)'/g)].map((q) => q[1]); + // And it is the list that actually returns 'document'. + for (const ext of clientExts) expect(kind({ name: `report.${ext}`, type: '' })).toBe('document'); + expect(clientExts.sort(), 'image-input.js _promptAttachKind() document list').toEqual(DOCUMENT_EXTS); + }); + + it('index.html #attachFileInput accept= lists exactly the server document extensions', () => { + const input = new JSDOM(read('index.html')).window.document.getElementById('attachFileInput'); + expect(input, '#attachFileInput not found in index.html').not.toBeNull(); + const exts = acceptDocumentExts(input!.getAttribute('accept') ?? ''); + expect(exts, 'index.html #attachFileInput accept=').toEqual(DOCUMENT_EXTS); + }); + + it('keyboard-accessory.js: both .paste-file-input accept= list exactly the server document extensions', () => { + const accepts = [...read('keyboard-accessory.js').matchAll(/class="paste-file-input" accept="([^"]*)"/g)].map( + (m) => m[1] + ); + // The Compose picker and the paste dialog's picker. + expect(accepts.length, 'expected two .paste-file-input pickers in keyboard-accessory.js').toBe(2); + accepts.forEach((accept, i) => { + expect(acceptDocumentExts(accept), `keyboard-accessory.js .paste-file-input #${i + 1} accept=`).toEqual( + DOCUMENT_EXTS + ); + }); + }); + + describe('constants.js file-path link patterns link an uploaded document path', () => { + const context = vm.createContext({}); + vm.runInContext( + `${read('constants.js')}\nglobalThis.__p = { FILE_PATH_LINK_PATTERN, RELATIVE_FILE_PATH_LINK_PATTERN };`, + context, + { filename: 'constants.js' } + ); + const { FILE_PATH_LINK_PATTERN, RELATIVE_FILE_PATH_LINK_PATTERN } = ( + context as unknown as { __p: Record } + ).__p; + + it('FILE_PATH_LINK_PATTERN matches a rooted path for every document extension', () => { + const missing = DOCUMENT_EXTS.filter((ext) => { + const path = `/home/u/case/.codeman-uploads/paste-1-ab-report.${ext}`; + return new RegExp(FILE_PATH_LINK_PATTERN.source, 'g').exec(path)?.[1] !== path; + }); + expect(missing, `constants.js FILE_PATH_LINK_PATTERN does not link: ${missing.join(', ')}`).toEqual([]); + }); + + it('RELATIVE_FILE_PATH_LINK_PATTERN matches a relative path for every document extension', () => { + const missing = DOCUMENT_EXTS.filter((ext) => { + const path = `.codeman-uploads/paste-1-ab-report.${ext}`; + return new RegExp(RELATIVE_FILE_PATH_LINK_PATTERN.source, 'g').exec(`saved ${path}`)?.[2] !== path; + }); + expect(missing, `constants.js RELATIVE_FILE_PATH_LINK_PATTERN does not link: ${missing.join(', ')}`).toEqual([]); + }); + }); +}); From b67bb076af31341bcc54fb28ee4d3dae30715847 Mon Sep 17 00:00:00 2001 From: Codeman maintainer Date: Sun, 18 Oct 2026 08:08:24 +0200 Subject: [PATCH 2/3] test(#653): pin one document branch and the pickers' MIME patterns The reverse classifier check read only the one includes(ext) list, so a second branch such as `if (ext === 'rtf') return 'document';` passed every test; the method may now return 'document' from exactly one place. The accept= checks kept only .ext tokens, so adding text/* to a picker passed; the non-extension tokens must be exactly image/* and video/*. Both verified by mutation (each fails the suite). Co-Authored-By: Claude Opus 5.5 (1M context) --- test/upload-document-list-parity.test.ts | 29 ++++++++++++++++++++++-- 1 file changed, 27 insertions(+), 2 deletions(-) diff --git a/test/upload-document-list-parity.test.ts b/test/upload-document-list-parity.test.ts index 05aa23f8..64c346df 100644 --- a/test/upload-document-list-parity.test.ts +++ b/test/upload-document-list-parity.test.ts @@ -44,6 +44,22 @@ function acceptDocumentExts(accept: string): string[] { .sort(); } +/** The non-extension tokens of an `accept=` value (MIME patterns such as `image/*`), sorted. */ +function acceptOtherTokens(accept: string): string[] { + return accept + .split(',') + .map((t) => t.trim()) + .filter((t) => t && !t.startsWith('.')) + .sort(); +} + +/** + * The only MIME patterns a prompt picker offers. A wildcard such as `text/*` here would + * let the picker hand over files the extension list above never names, which the + * extension check alone cannot see. + */ +const PICKER_MIME_TOKENS = ['image/*', 'video/*']; + type AttachKind = (file: { name: string; type: string }) => string | null; function loadPromptAttachKind(): { kind: AttachKind; method: string } { @@ -71,6 +87,10 @@ describe('prompt-upload document list parity (ALLOWED_DOCUMENT_EXTS ⇄ browser const { kind, method } = loadPromptAttachKind(); const found = [...method.matchAll(/if \(\[([^\]]+)\]\.includes\(ext\)\) \{\s*return 'document';/g)]; expect(found.length, 'expected one document extension list in image-input.js _promptAttachKind()').toBe(1); + // A second document branch (`if (ext === 'rtf') return 'document';`) would slip past the + // list check above, so the method may return 'document' from exactly one place. + const returns = method.match(/return 'document'/g) ?? []; + expect(returns.length, "image-input.js _promptAttachKind() must return 'document' from one place only").toBe(1); const clientExts = [...found[0][1].matchAll(/'([^']+)'/g)].map((q) => q[1]); // And it is the list that actually returns 'document'. for (const ext of clientExts) expect(kind({ name: `report.${ext}`, type: '' })).toBe('document'); @@ -80,8 +100,9 @@ describe('prompt-upload document list parity (ALLOWED_DOCUMENT_EXTS ⇄ browser it('index.html #attachFileInput accept= lists exactly the server document extensions', () => { const input = new JSDOM(read('index.html')).window.document.getElementById('attachFileInput'); expect(input, '#attachFileInput not found in index.html').not.toBeNull(); - const exts = acceptDocumentExts(input!.getAttribute('accept') ?? ''); - expect(exts, 'index.html #attachFileInput accept=').toEqual(DOCUMENT_EXTS); + const accept = input!.getAttribute('accept') ?? ''; + expect(acceptDocumentExts(accept), 'index.html #attachFileInput accept=').toEqual(DOCUMENT_EXTS); + expect(acceptOtherTokens(accept), 'index.html #attachFileInput accept= MIME patterns').toEqual(PICKER_MIME_TOKENS); }); it('keyboard-accessory.js: both .paste-file-input accept= list exactly the server document extensions', () => { @@ -94,6 +115,10 @@ describe('prompt-upload document list parity (ALLOWED_DOCUMENT_EXTS ⇄ browser expect(acceptDocumentExts(accept), `keyboard-accessory.js .paste-file-input #${i + 1} accept=`).toEqual( DOCUMENT_EXTS ); + expect( + acceptOtherTokens(accept), + `keyboard-accessory.js .paste-file-input #${i + 1} accept= MIME patterns` + ).toEqual(PICKER_MIME_TOKENS); }); }); From d8850d1b5056b00265c2b076e9bd11440ff37119 Mon Sep 17 00:00:00 2001 From: Codeman maintainer Date: Sun, 18 Oct 2026 08:08:24 +0200 Subject: [PATCH 3/3] fix(#653): export ALLOWED_DOCUMENT_EXTS as ReadonlySet Now that the set is exported for the parity test, type it read-only like VIDEO_ATTACHMENT_EXTENSIONS, so an importer cannot add to the live upload allowlist. Co-Authored-By: Claude Opus 5.5 (1M context) --- src/web/routes/session-routes.ts | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/src/web/routes/session-routes.ts b/src/web/routes/session-routes.ts index e3980224..10ebe31a 100644 --- a/src/web/routes/session-routes.ts +++ b/src/web/routes/session-routes.ts @@ -338,7 +338,14 @@ const TEXT_UPLOAD_EXTS = new Set(['.txt', '.md', '.csv', '.tsv', '.json', '.log' * imageMagicMatchesExt (signature, or UTF-8 without NUL for text). The browser * keeps its own copies (test/upload-document-list-parity.test.ts pins them). */ -export const ALLOWED_DOCUMENT_EXTS = new Set(['.pdf', '.docx', '.pptx', '.xlsx', '.zip', ...TEXT_UPLOAD_EXTS]); +export const ALLOWED_DOCUMENT_EXTS: ReadonlySet = new Set([ + '.pdf', + '.docx', + '.pptx', + '.xlsx', + '.zip', + ...TEXT_UPLOAD_EXTS, +]); /** * Validate upload bytes against a declared extension. Sniffs the first ~12 bytes