server/bot-attachment.ts
Rama main · réplica de lectura del repositorio original.
// The source opens through the message-file route's hardened resolver (realpath containment, O_NOFOLLOW, size cap) and is
// copied into the private store, so the chat never reaches into a bot's workspace or VM home afterwards.
import { basename, join, posix } from "node:path";
import {
extensionForFileMime,
extensionForMime,
FILE_MAX_BYTES,
IMAGE_MAX_BYTES,
saveFile,
saveImage,
} from "./attachments.ts";
import { mimeFor, openFileSavedSince, openMessageFile, type OpenedMessageFile } from "./message-file.ts";
import type { Message } from "./store.ts";
export type BotAttachment = NonNullable<Message["attachments"]>[number];
export const ATTACH_SUPPORTED_TYPES =
"images (png, jpg, gif, webp), video (mp4, webm, mov), audio (mp3, m4a, aac, wav, ogg, opus, flac), " +
"pdf, Word/Excel/PowerPoint and OpenDocument files, and csv, tsv, txt, md, json, rtf";
function statusError(status: number, message: string): Error & { status: number } {
return Object.assign(new Error(message), { status });
}
/** Maps a guest workspace path onto its bind-mounted host directory; anything outside it, `..` escapes included, is null. */
export function guestWorkspaceToHost(input: string, guestRoot: string, hostRoot: string): string | null {
let value = input.trim();
if (/^file:\/\//i.test(value)) {
try {
value = decodeURIComponent(new URL(value).pathname);
} catch {
return null;
}
}
if (!value.startsWith("/")) return null;
const normalized = posix.normalize(value);
const relative = posix.relative(guestRoot, normalized);
if (!relative || relative === ".." || relative.startsWith("../") || posix.isAbsolute(relative)) return null;
return join(hostRoot, ...relative.split("/"));
}
/** Directory parts are dropped, never trusted. */
function displayNameFor(requested: string | undefined, fallback: string): string {
const cleaned = requested?.trim().replace(/\\/g, "/");
const name = cleaned ? posix.basename(cleaned) : "";
return name || fallback;
}
export interface SaveBotAttachmentInput {
path: string;
name?: string;
/** Host directories the bot may read from (its working folder, workspace). */
roots: readonly string[];
guest?: { root: string; host: string };
/** A file this turn saved outside the roots, at a place the person named: one copied file, never a folder or a link grant. */
savedThisTurn?: { since: number; refuse: readonly string[] };
}
/** Only the roots' outside_workspace refusal falls through, so a missing or oversized file in the folder says exactly that.
* Opened at the store's limit, not the preview one: the copy streams, and a person may send the same size. */
async function openSource(path: string, roots: readonly string[], savedThisTurn: SaveBotAttachmentInput["savedThisTurn"]): Promise<OpenedMessageFile> {
try {
return await openMessageFile(path, roots, FILE_MAX_BYTES);
} catch (error) {
if (!savedThisTurn || (error as { code?: unknown } | null)?.code !== "outside_workspace") throw error;
return openFileSavedSince(path, savedThisTurn, FILE_MAX_BYTES);
}
}
export interface SavedBotAttachment {
attachment: BotAttachment;
bytes: number;
}
export async function saveBotAttachment(input: SaveBotAttachmentInput): Promise<SavedBotAttachment> {
const requested = input.path.trim();
if (!requested) throw statusError(400, "path is required");
const guestHost = input.guest ? guestWorkspaceToHost(requested, input.guest.root, input.guest.host) : null;
// A bot with a VM works in it: a relative path means its VM workspace first.
const roots = input.guest ? [input.guest.host, ...input.roots] : input.roots;
// A VM turn's tools never wrote to the host, so it gets roots only.
const file = await openSource(guestHost ?? requested, roots, input.guest ? undefined : input.savedThisTurn);
try {
const mime = mimeFor(file.name);
const name = displayNameFor(input.name, file.name);
if (extensionForMime(mime)) {
if (file.bytes > IMAGE_MAX_BYTES) throw statusError(413, `image exceeds ${IMAGE_MAX_BYTES} bytes`);
const saved = saveImage(await file.handle.readFile(), mime);
return { attachment: { kind: "image", path: saved.path, mime: saved.mime, name, source: file.path }, bytes: saved.bytes };
}
if (!extensionForFileMime(mime)) {
throw statusError(415, `${file.name} is not a supported attachment type. Supported: ${ATTACH_SUPPORTED_TYPES}.`);
}
if (file.bytes > FILE_MAX_BYTES) throw statusError(413, `file exceeds ${FILE_MAX_BYTES} bytes`);
const saved = await saveFile(file.handle.createReadStream({ start: 0, autoClose: false }), name || basename(file.name), mime, {
expectedBytes: file.bytes,
});
return { attachment: { kind: "file", path: saved.path, mime: saved.mime, name: saved.name, source: file.path }, bytes: saved.bytes };
} finally {
await file.handle.close().catch(() => undefined);
}
}
/** The chat's link rule: search from the reply back to the last non-bot message; only the reply's author counts, and a
* name delivered twice matches neither. */
export function deliveredCopy(messages: readonly Message[], reply: Message, name: string): Exclude<BotAttachment, { kind: "audio" }> | null {
const byId = new Map(messages.map((message) => [message.id, message]));
const author = reply.from?.botId;
let found: Exclude<BotAttachment, { kind: "audio" }> | null = null;
for (let message: Message | undefined = reply; message?.role === "bot"; message = message.parentId ? byId.get(message.parentId) : undefined) {
if (message.from?.botId !== author) continue;
for (const attachment of message.attachments ?? []) {
if (attachment.kind === "audio" || attachment.name !== name) continue;
if (found) return null;
found = attachment;
}
}
return found;
}
export type AttachForTurnOutcome<S> =
| { status: "attached"; saved: S }
| { status: "limit" }
| { status: "ended" };
/** The slot is reserved before the await, so parallel calls cannot all pass the per-turn count; `stillLive` is asked again
* just before `publish`, so nothing is published for a turn that ended meanwhile. */
export async function attachForTurn<S>(
turn: { attachedFiles?: number },
max: number,
hooks: {
save: () => Promise<S>;
stillLive: () => boolean;
publish: (saved: S) => void;
discard: (saved: S) => void;
},
): Promise<AttachForTurnOutcome<S>> {
const used = turn.attachedFiles ?? 0;
if (used >= max) return { status: "limit" };
turn.attachedFiles = used + 1;
let saved: S | undefined;
let published = false;
try {
saved = await hooks.save();
if (!hooks.stillLive()) return { status: "ended" };
hooks.publish(saved);
published = true;
return { status: "attached", saved };
} finally {
if (!published) {
turn.attachedFiles = Math.max(0, (turn.attachedFiles ?? 1) - 1);
if (saved !== undefined) hooks.discard(saved);
}
}
}