agent-manager-template / server /src /attachments.js
Agent Manager
Fix abandoned and cancelable uploads
60395e6
Raw
History Blame Contribute Delete
16.2 kB
import crypto from 'node:crypto';
import fs from 'node:fs';
import path from 'node:path';
import { Transform } from 'node:stream';
import { pipeline } from 'node:stream/promises';
import { STATE_DIR } from './config.js';
export const ATTACHMENT_LIMIT = 100 * 1024 * 1024;
// A single prompt is capped separately at five files. This lifetime cap keeps
// a forgotten session from growing without bound while still leaving room for
// many ordinary attachment turns.
export const SESSION_ATTACHMENT_LIMIT = 500 * 1024 * 1024;
export const SESSION_ATTACHMENT_COUNT_LIMIT = 200;
export const ATTACHMENT_ID = /^att_[a-f0-9]{24}$/;
export const IMAGE_MIMES = Object.freeze([
'image/png',
'image/jpeg',
'image/webp',
'image/gif',
]);
const ATTACHMENTS_DIR = path.join(STATE_DIR, 'attachments');
const IMAGE_EXTENSIONS = Object.freeze({
'image/png': 'png',
'image/jpeg': 'jpg',
'image/webp': 'webp',
'image/gif': 'gif',
});
const MIME_BY_EXTENSION = Object.freeze({
...Object.fromEntries(Object.entries(IMAGE_EXTENSIONS).map(([mime, extension]) => [extension, mime])),
jpeg: 'image/jpeg',
pdf: 'application/pdf',
doc: 'application/msword',
docx: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
xls: 'application/vnd.ms-excel',
xlsx: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
ppt: 'application/vnd.ms-powerpoint',
pptx: 'application/vnd.openxmlformats-officedocument.presentationml.presentation',
odt: 'application/vnd.oasis.opendocument.text',
ods: 'application/vnd.oasis.opendocument.spreadsheet',
odp: 'application/vnd.oasis.opendocument.presentation',
txt: 'text/plain',
md: 'text/markdown',
csv: 'text/csv',
html: 'text/html',
htm: 'text/html',
css: 'text/css',
js: 'text/javascript',
mjs: 'text/javascript',
svg: 'image/svg+xml',
json: 'application/json',
yaml: 'application/yaml',
yml: 'application/yaml',
xml: 'application/xml',
rtf: 'application/rtf',
zip: 'application/zip',
gz: 'application/gzip',
tar: 'application/x-tar',
'7z': 'application/x-7z-compressed',
epub: 'application/epub+zip',
});
const ATTACHMENT_FILE = /^att_[a-f0-9]{24}(?:[-.]|$)/;
const uploadWindows = new Map();
const uploadLocks = new Map();
function httpError(statusCode, message) {
const error = new Error(message);
error.statusCode = statusCode;
return error;
}
function sessionDir(sessionId) {
// Session ids are server-generated slugs. Keep this check here as a second
// boundary: a future caller must not turn an attachment lookup into a path
// join with an arbitrary browser value.
if (!/^[a-z0-9][a-z0-9-]{0,100}$/.test(String(sessionId))) {
throw httpError(400, 'session cannot accept files');
}
return path.join(ATTACHMENTS_DIR, sessionId);
}
function checkUploadRate(sessionId) {
const now = Date.now();
const recent = (uploadWindows.get(sessionId) || []).filter((at) => now - at < 60_000);
if (recent.length >= 20) throw httpError(429, 'too many file uploads — try again in a minute');
recent.push(now);
uploadWindows.set(sessionId, recent);
}
// Serialize writes within one session so concurrent uploads cannot each pass a
// stale quota check. Different sessions still stream in parallel.
async function withUploadLock(sessionId, task) {
const previous = uploadLocks.get(sessionId) || Promise.resolve();
let release;
const hold = new Promise((resolve) => { release = resolve; });
const tail = previous.catch(() => {}).then(() => hold);
uploadLocks.set(sessionId, tail);
await previous.catch(() => {});
try {
return await task();
} finally {
release();
if (uploadLocks.get(sessionId) === tail) uploadLocks.delete(sessionId);
}
}
async function attachmentUsage(dir) {
let entries = [];
try { entries = await fs.promises.readdir(dir); } catch { return { bytes: 0, count: 0 }; }
let bytes = 0;
let count = 0;
for (const name of entries) {
try {
const stat = await fs.promises.lstat(path.join(dir, name));
if (!stat.isFile() || stat.isSymbolicLink()) continue;
bytes += stat.size;
if (ATTACHMENT_FILE.test(name)) count += 1;
} catch {}
}
return { bytes, count };
}
export function detectImageMime(bytes) {
if (bytes.length >= 8
&& bytes[0] === 0x89 && bytes.subarray(1, 4).toString('ascii') === 'PNG'
&& bytes[4] === 0x0d && bytes[5] === 0x0a && bytes[6] === 0x1a && bytes[7] === 0x0a) {
return 'image/png';
}
if (bytes.length >= 3 && bytes[0] === 0xff && bytes[1] === 0xd8 && bytes[2] === 0xff) {
return 'image/jpeg';
}
if (bytes.length >= 6 && ['GIF87a', 'GIF89a'].includes(bytes.subarray(0, 6).toString('ascii'))) {
return 'image/gif';
}
if (bytes.length >= 12
&& bytes.subarray(0, 4).toString('ascii') === 'RIFF'
&& bytes.subarray(8, 12).toString('ascii') === 'WEBP') {
return 'image/webp';
}
return null;
}
function imageEnvelopeIsValid(mime, header, tail, totalBytes) {
if (mime === 'image/png') {
return totalBytes >= 45 && header.length >= 24
&& header.readUInt32BE(8) === 13
&& header.subarray(12, 16).toString('ascii') === 'IHDR'
&& header.readUInt32BE(16) > 0 && header.readUInt32BE(20) > 0
&& tail.length >= 12
&& tail.readUInt32BE(tail.length - 12) === 0
&& tail.subarray(tail.length - 8, tail.length - 4).toString('ascii') === 'IEND';
}
if (mime === 'image/jpeg') {
return totalBytes >= 6 && tail.length >= 2
&& tail[tail.length - 2] === 0xff && tail[tail.length - 1] === 0xd9;
}
if (mime === 'image/gif') {
return totalBytes >= 14 && header.length >= 10
&& header.readUInt16LE(6) > 0 && header.readUInt16LE(8) > 0
&& tail[tail.length - 1] === 0x3b;
}
if (mime === 'image/webp') {
const chunk = header.subarray(12, 16).toString('ascii');
return totalBytes >= 20 && header.length >= 16
&& ['VP8 ', 'VP8L', 'VP8X'].includes(chunk)
&& header.readUInt32LE(4) + 8 <= totalBytes;
}
return false;
}
const normalizedMime = (value) => {
const mime = String(value || '').split(';', 1)[0].trim().toLowerCase();
const canonical = mime === 'image/jpg' || mime === 'image/pjpeg' ? 'image/jpeg' : mime;
return /^[a-z0-9][a-z0-9!#$&^_.+-]*\/[a-z0-9][a-z0-9!#$&^_.+-]*$/.test(canonical)
? canonical : '';
};
const extensionOf = (name) => path.extname(name).slice(1).toLowerCase();
const mimeForName = (name) => {
const known = MIME_BY_EXTENSION[extensionOf(name)];
return (typeof known === 'string' ? known : '') || 'application/octet-stream';
};
function truncateUtf8(value, maxBytes) {
let result = value;
while (Buffer.byteLength(result) > maxBytes) result = result.slice(0, -1);
return result;
}
function safeFileName(value) {
let decoded = String(Array.isArray(value) ? value[0] : value || 'attachment');
try { decoded = decodeURIComponent(decoded); } catch {}
let name = path.basename(decoded.replace(/\\/g, '/'))
.normalize('NFKC')
.replace(/[\u0000-\u001f\u007f/\\]/g, '_')
.trim();
if (!name || name === '.' || name === '..') name = 'attachment';
if (Buffer.byteLength(name) > 180) {
const candidate = path.extname(name);
const extension = Buffer.byteLength(candidate) <= 24 ? candidate : '';
const stem = extension ? name.slice(0, -extension.length) : name;
name = `${truncateUtf8(stem, 180 - Buffer.byteLength(extension))}${extension}`;
}
return name || 'attachment';
}
function canonicalImageName(name, mime) {
const extension = IMAGE_EXTENSIONS[mime];
const current = path.extname(name);
const stem = current ? name.slice(0, -current.length) : name;
return `${stem || 'Screenshot'}.${extension}`;
}
const isImageMime = (mime) => IMAGE_MIMES.includes(mime);
const claimsNativeImage = (name, declared) =>
isImageMime(normalizedMime(declared))
|| Object.values(IMAGE_EXTENSIONS).includes(extensionOf(name))
|| extensionOf(name) === 'jpeg';
const responseShape = (sessionId, id, mime, bytes, filePath, name = path.basename(filePath)) => {
const kind = isImageMime(mime) ? 'image' : 'file';
return {
id,
kind,
name,
mime,
bytes,
path: filePath,
previewUrl: `/api/sessions/${encodeURIComponent(sessionId)}/attachments/${id}/raw`,
insertText: `${kind === 'image' ? 'Screenshot' : 'File'}: ${filePath} `,
};
};
/** Stream one browser file into the session-owned attachment store. */
export async function receiveAttachment(readable, sessionId, { contentType = '', fileName = '' } = {}) {
// Image metadata is advisory. Byte detection remains the security boundary
// for formats rendered inline or passed to a CLI's native image interface.
// Other formats are inert files: they are never executed, and the raw route
// forces them to download rather than rendering browser-active content.
const originalName = safeFileName(fileName);
checkUploadRate(sessionId);
return withUploadLock(sessionId, async () => {
const dir = sessionDir(sessionId);
await fs.promises.mkdir(dir, { recursive: true });
const usage = await attachmentUsage(dir);
if (usage.count >= SESSION_ATTACHMENT_COUNT_LIMIT) {
throw httpError(413, `this session already has ${SESSION_ATTACHMENT_COUNT_LIMIT} files`);
}
const id = `att_${crypto.randomBytes(12).toString('hex')}`;
const temporary = path.join(dir, `.${id}.${crypto.randomBytes(4).toString('hex')}.part`);
let bytes = 0;
const limiter = new Transform({
transform(chunk, _encoding, callback) {
bytes += chunk.length;
if (bytes > ATTACHMENT_LIMIT) return callback(httpError(413, 'file is larger than 100 MB'));
if (usage.bytes + bytes > SESSION_ATTACHMENT_LIMIT) {
return callback(httpError(413, 'this session has reached its 500 MB attachment limit'));
}
callback(null, chunk);
},
});
try {
await pipeline(readable, limiter, fs.createWriteStream(temporary, { flags: 'wx' }));
if (bytes === 0) throw httpError(413, 'file is empty');
const handle = await fs.promises.open(temporary, 'r');
const header = Buffer.alloc(32);
const tail = Buffer.alloc(Math.min(16, bytes));
let bytesRead = 0;
let tailBytesRead = 0;
try {
({ bytesRead } = await handle.read(header, 0, header.length, 0));
({ bytesRead: tailBytesRead } = await handle.read(tail, 0, tail.length, Math.max(0, bytes - tail.length)));
} finally {
await handle.close();
}
const headerBytes = header.subarray(0, bytesRead);
const tailBytes = tail.subarray(0, tailBytesRead);
const detected = detectImageMime(headerBytes);
if (detected && !imageEnvelopeIsValid(detected, headerBytes, tailBytes, bytes)) {
throw httpError(415, 'image is truncated or malformed');
}
if (!detected && claimsNativeImage(originalName, contentType)) {
throw httpError(415, 'file does not contain a valid PNG, JPEG, GIF, or WebP image');
}
const storedName = detected ? canonicalImageName(originalName, detected) : originalName;
const mime = detected || mimeForName(storedName);
const finalPath = path.join(dir, `${id}-${storedName}`);
await fs.promises.rename(temporary, finalPath);
return responseShape(sessionId, id, mime, bytes, finalPath, storedName);
} catch (error) {
await fs.promises.unlink(temporary).catch(() => {});
throw error;
}
});
}
/** Resolve an untrusted attachment id within exactly one session. */
export function resolveAttachment(sessionId, attachmentId) {
if (!ATTACHMENT_ID.test(String(attachmentId))) throw httpError(404, 'attachment not found');
const dir = sessionDir(sessionId);
let names = [];
try { names = fs.readdirSync(dir); } catch {}
for (const name of names) {
const isCurrent = name.startsWith(`${attachmentId}-`);
const legacyExtension = name.startsWith(`${attachmentId}.`) ? extensionOf(name) : '';
if (!isCurrent && !Object.values(IMAGE_EXTENSIONS).includes(legacyExtension)) continue;
const filePath = path.join(dir, name);
try {
const stat = fs.lstatSync(filePath);
if (!stat.isFile() || stat.isSymbolicLink()) continue;
const displayName = isCurrent ? name.slice(attachmentId.length + 1) : name;
return responseShape(sessionId, attachmentId, mimeForName(displayName), stat.size, filePath, displayName);
} catch {}
}
throw httpError(404, 'attachment not found');
}
export function resolveAttachments(sessionId, attachmentIds) {
if (!Array.isArray(attachmentIds)) throw httpError(400, 'attachmentIds must be an array');
if (attachmentIds.length > 5) throw httpError(400, 'at most five files may be attached');
if (new Set(attachmentIds).size !== attachmentIds.length) throw httpError(400, 'duplicate attachment id');
return attachmentIds.map((id) => resolveAttachment(sessionId, id));
}
/** Remove one unsent attachment without disturbing files the session still uses. */
export async function removeAttachment(sessionId, attachmentId) {
return withUploadLock(sessionId, async () => {
const attachment = resolveAttachment(sessionId, attachmentId);
await fs.promises.unlink(attachment.path);
return attachment;
});
}
export async function removeSessionAttachments(sessionId) {
uploadWindows.delete(sessionId);
await withUploadLock(sessionId, () => fs.promises.rm(sessionDir(sessionId), { recursive: true, force: true }));
}
/** Remove only old orphan stores; recent crash leftovers keep a seven-day grace. */
export async function pruneAttachmentDirs(sessionIds, now = Date.now()) {
const live = new Set(sessionIds);
const cutoff = now - 7 * 24 * 60 * 60 * 1000;
let entries = [];
try { entries = await fs.promises.readdir(ATTACHMENTS_DIR, { withFileTypes: true }); } catch { return; }
for (const entry of entries) {
if (!entry.isDirectory()) continue;
const dir = path.join(ATTACHMENTS_DIR, entry.name);
// A process death can happen between exclusive temp creation and rename.
// Normal request failures remove these immediately; this is the crash
// backstop, with the same grace period as orphan session directories.
try {
for (const name of await fs.promises.readdir(dir)) {
const part = path.join(dir, name);
const stat = await fs.promises.lstat(part);
if (name.endsWith('.part') && stat.isFile() && !stat.isSymbolicLink() && stat.mtimeMs < cutoff) {
await fs.promises.unlink(part);
}
}
} catch {}
if (live.has(entry.name)) continue;
let newest = 0;
try {
const names = await fs.promises.readdir(dir);
const stats = await Promise.all(names.map((name) => fs.promises.lstat(path.join(dir, name))));
newest = Math.max((await fs.promises.stat(dir)).mtimeMs, ...stats.map((stat) => stat.mtimeMs));
} catch { continue; }
if (newest < cutoff) await fs.promises.rm(dir, { recursive: true, force: true });
}
}
const quotePath = (filePath) => JSON.stringify(filePath);
/** TUI commands that attach native image context before the textual prompt. */
export function formatAttachmentPrelude(cli, attachments) {
if (cli !== 'hermes') return [];
return attachments
.filter((attachment) => attachment.kind === 'image')
.map((image) => `/image ${quotePath(image.path)}`);
}
/** Keep CLI-version-specific formatting out of request handlers and React. */
export function formatAttachmentDelivery(cli, text, attachments) {
const onlyImages = attachments.length > 0 && attachments.every((attachment) => attachment.kind === 'image');
const prompt = String(text || '').trim() || (onlyImages
? `Please inspect the attached screenshot${attachments.length === 1 ? '' : 's'}.`
: `Please inspect the attached file${attachments.length === 1 ? '' : 's'}.`);
if (!attachments.length) return prompt;
const paths = attachments.map((attachment) => attachment.path);
if (cli === 'gemini') {
return `${prompt}\n\n${paths.map((filePath) => `@${quotePath(filePath)}`).join('\n')}`;
}
return `${prompt}\n\nAttached files:\n${paths.map((filePath) => `- ${quotePath(filePath)}`).join('\n')}`;
}