server/bot-attachment.test.ts
Rama main · réplica de lectura del repositorio original.
import { existsSync, mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync, symlinkSync, truncateSync, utimesSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { pathToFileURL } from "node:url";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
// attachments.ts reads DATA_DIR at import time.
const DATA_ROOT = mkdtempSync(join(tmpdir(), "omb-bot-attachment-"));
process.env.OMB_DATA_DIR = join(DATA_ROOT, "data");
const { attachForTurn, deliveredCopy, guestWorkspaceToHost, saveBotAttachment } = await import("./bot-attachment.ts");
const { ATTACHMENTS_DIR, FILE_MAX_BYTES, PREVIEW_MAX_BYTES } = await import("./attachments.ts");
const WORK = join(DATA_ROOT, "work");
const VM_HOME = join(DATA_ROOT, "vm-home");
const PNG = Buffer.concat([Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]), Buffer.from("rest-of-png")]);
beforeAll(() => {
mkdirSync(WORK, { recursive: true });
mkdirSync(join(VM_HOME, "reports"), { recursive: true });
});
afterAll(() => rmSync(DATA_ROOT, { recursive: true, force: true }));
describe("guestWorkspaceToHost", () => {
const guest = "/home/cua/workspace";
const host = join("C:", "vm-home");
it("maps a guest path and a file:// URL onto the bind-mounted host directory", () => {
expect(guestWorkspaceToHost("/home/cua/workspace/reports/q3.pdf", guest, host)).toBe(join(host, "reports", "q3.pdf"));
expect(guestWorkspaceToHost("file:///home/cua/workspace/a%20b.mp3", guest, host)).toBe(join(host, "a b.mp3"));
});
it("refuses anything that is not a file inside the guest workspace", () => {
for (const bad of [
"/home/cua/workspace",
"/home/cua/workspace/",
"/home/cua/workspace/../.ssh/id_rsa",
"/home/cua/workspace/a/../../other",
"/home/cua/other/file.pdf",
"/etc/passwd",
"relative/file.pdf",
"file:///etc/passwd",
"",
]) {
expect(guestWorkspaceToHost(bad, guest, host), bad).toBeNull();
}
});
});
describe("saveBotAttachment", () => {
const cases: Array<[string, string, "image" | "file", string]> = [
["chart.png", "png", "image", "image/png"],
["photo.jpeg", "jpeg", "image", "image/jpeg"],
["anim.gif", "gif", "image", "image/gif"],
["report.pdf", "pdf", "file", "application/pdf"],
["clip.mp4", "mp4", "file", "video/mp4"],
["song.mp3", "mp3", "file", "audio/mpeg"],
["deck.pptx", "pptx", "file", "application/vnd.openxmlformats-officedocument.presentationml.presentation"],
["book.xlsx", "xlsx", "file", "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"],
["table.csv", "csv", "file", "text/csv"],
];
it.each(cases)("attaches %s as a %s", async (name, _label, kind, mime) => {
const bytes = kind === "image" ? PNG : Buffer.from(`bytes of ${name}`);
writeFileSync(join(WORK, name), bytes);
const saved = await saveBotAttachment({ path: name, roots: [WORK] });
expect(saved.attachment.kind).toBe(kind);
expect(saved.attachment.mime).toBe(mime);
expect(saved.bytes).toBe(bytes.byteLength);
expect(saved.attachment.path.startsWith(ATTACHMENTS_DIR)).toBe(true);
expect(readFileSync(saved.attachment.path)).toEqual(bytes);
if (saved.attachment.kind === "file") expect(saved.attachment.name.toLowerCase()).toBe(name.toLowerCase());
// The desktop app shows the original in its folder, not the copy.
expect(saved.attachment).toMatchObject({ source: realpathSync.native(join(WORK, name)) });
});
it("reads a file from the VM guest workspace through its host mount", async () => {
writeFileSync(join(VM_HOME, "reports", "nekoneko.pdf"), "%PDF-1.4 fake");
const saved = await saveBotAttachment({
path: "/home/cua/workspace/reports/nekoneko.pdf",
name: "決算書.pdf",
roots: [WORK],
guest: { root: "/home/cua/workspace", host: VM_HOME },
});
expect(saved.attachment).toMatchObject({ kind: "file", mime: "application/pdf", name: "決算書.pdf",
source: realpathSync.native(join(VM_HOME, "reports", "nekoneko.pdf")) });
expect(readFileSync(saved.attachment.path, "utf8")).toBe("%PDF-1.4 fake");
});
it("attaches a VM video over the preview size, up to the same limit a person's upload has", async () => {
// A bot on a VM has no link to fall back on: attaching is the only way its video reaches the chat.
const guest = { root: "/home/cua/workspace", host: VM_HOME };
const reel = join(VM_HOME, "reports", "reel.mp4");
writeFileSync(reel, "x");
truncateSync(reel, PREVIEW_MAX_BYTES + 1);
const saved = await saveBotAttachment({ path: "/home/cua/workspace/reports/reel.mp4", roots: [WORK], guest });
expect(saved).toMatchObject({ bytes: PREVIEW_MAX_BYTES + 1, attachment: { kind: "file", mime: "video/mp4", name: "reel.mp4" } });
truncateSync(reel, FILE_MAX_BYTES + 1);
await expect(saveBotAttachment({ path: "/home/cua/workspace/reports/reel.mp4", roots: [WORK], guest }))
.rejects.toMatchObject({ status: 413, code: "file_too_large", errorParams: { limit: "100 MB", name: "reel.mp4" } });
rmSync(reel);
});
it("does not let a guest path or a name reach outside its folder", async () => {
writeFileSync(join(DATA_ROOT, "secret.pdf"), "secret");
const guest = { root: "/home/cua/workspace", host: VM_HOME };
await expect(saveBotAttachment({ path: "/home/cua/workspace/../../secret.pdf", roots: [WORK], guest })).rejects.toMatchObject({ status: 404 });
await expect(saveBotAttachment({ path: join(DATA_ROOT, "secret.pdf"), roots: [WORK] })).rejects.toMatchObject({ status: 403 });
writeFileSync(join(WORK, "plain.txt"), "hello");
const saved = await saveBotAttachment({ path: "plain.txt", name: "../../evil/notes.txt", roots: [WORK] });
expect(saved.attachment).toMatchObject({ kind: "file", name: "notes.txt" });
});
it("names what is supported when the type is not", async () => {
for (const name of ["page.html", "logo.svg", "run.exe", "bundle.zip", "noextension"]) {
writeFileSync(join(WORK, name), "x");
await expect(saveBotAttachment({ path: name, roots: [WORK] }), name).rejects.toMatchObject({
status: 415,
message: expect.stringContaining("Supported:"),
});
}
});
it("rejects a missing path and an empty request", async () => {
await expect(saveBotAttachment({ path: "nope.pdf", roots: [WORK] })).rejects.toMatchObject({ status: 404 });
await expect(saveBotAttachment({ path: " ", roots: [WORK] })).rejects.toMatchObject({ status: 400 });
expect(existsSync(ATTACHMENTS_DIR)).toBe(true);
});
});
describe("saveBotAttachment for a file a turn saved outside its folders", () => {
// Where a person might ask for output: outside every root, not hidden.
const DESIGNS = join(DATA_ROOT, "Desktop", "designs", "2026-10-08_News");
const refuse = [process.env.OMB_DATA_DIR!];
const hourAgo = () => new Date(Date.now() - 3_600_000);
beforeAll(() => mkdirSync(DESIGNS, { recursive: true }));
it("copies it into the attachment store and leaves the original where it is", async () => {
const since = Date.now();
const source = join(DESIGNS, "00_غلاف.jpg");
writeFileSync(source, "jpeg-bytes");
// Bots on macOS write the URL unencoded; Windows needs a real file URL.
const href = process.platform === "win32" ? pathToFileURL(source).href : `file://${source}`;
const saved = await saveBotAttachment({ path: href, roots: [WORK], savedThisTurn: { since, refuse } });
expect(saved.attachment).toMatchObject({ kind: "image", mime: "image/jpeg", name: "00_غلاف.jpg", source: realpathSync.native(source) });
expect(saved.attachment.path.startsWith(ATTACHMENTS_DIR)).toBe(true);
expect(readFileSync(saved.attachment.path, "utf8")).toBe("jpeg-bytes");
expect(readFileSync(source, "utf8")).toBe("jpeg-bytes");
});
it("stays roots only without the grant, for a VM turn, and for a relative path", async () => {
const since = Date.now();
writeFileSync(join(DESIGNS, "fresh.pdf"), "%PDF-fresh");
const refusal = { status: 403, code: "outside_workspace" };
await expect(saveBotAttachment({ path: join(DESIGNS, "fresh.pdf"), roots: [WORK] })).rejects.toMatchObject(refusal);
await expect(saveBotAttachment({
path: join(DESIGNS, "fresh.pdf"),
roots: [WORK],
guest: { root: "/home/cua/workspace", host: VM_HOME },
savedThisTurn: { since, refuse },
})).rejects.toMatchObject(refusal);
await expect(saveBotAttachment({ path: "../Desktop/designs/2026-10-08_News/fresh.pdf", roots: [WORK], savedThisTurn: { since, refuse } }))
.rejects.toMatchObject(refusal);
});
it("refuses a file that was not written during this turn", async () => {
const old = join(DESIGNS, "last-week.pdf");
writeFileSync(old, "%PDF-old");
utimesSync(old, hourAgo(), hourAgo());
await expect(saveBotAttachment({ path: old, roots: [WORK], savedThisTurn: { since: Date.now(), refuse } }))
.rejects.toMatchObject({ status: 403, message: expect.stringContaining("not saved during this turn") });
});
it("refuses hidden folders and the app's data, however fresh", async () => {
const since = Date.now();
const hidden = join(DATA_ROOT, ".ssh");
mkdirSync(hidden, { recursive: true });
writeFileSync(join(hidden, "key.pdf"), "%PDF-key");
mkdirSync(join(process.env.OMB_DATA_DIR!, "workspaces"), { recursive: true });
writeFileSync(join(process.env.OMB_DATA_DIR!, "workspaces", "MEMORY.md"), "# private");
for (const path of [join(hidden, "key.pdf"), join(process.env.OMB_DATA_DIR!, "workspaces", "MEMORY.md")]) {
await expect(saveBotAttachment({ path, roots: [WORK], savedThisTurn: { since, refuse } }), path)
.rejects.toMatchObject({ status: 403, code: "outside_workspace" });
}
});
// creating a symlink needs extra rights on windows
it.skipIf(process.platform === "win32")("refuses a fresh link into a hidden folder", async () => {
const since = Date.now();
const hidden = join(DATA_ROOT, ".ssh");
mkdirSync(hidden, { recursive: true });
writeFileSync(join(hidden, "linked-key.pdf"), "%PDF-key");
symlinkSync(join(hidden, "linked-key.pdf"), join(DESIGNS, "innocent.pdf"));
await expect(saveBotAttachment({ path: join(DESIGNS, "innocent.pdf"), roots: [WORK], savedThisTurn: { since, refuse } }))
.rejects.toMatchObject({ status: 403, code: "outside_workspace" });
});
it("keeps every other check: folders, types, the size cap and a missing file in the roots", async () => {
const since = Date.now();
const turn = { roots: [WORK], savedThisTurn: { since, refuse } };
await expect(saveBotAttachment({ path: DESIGNS, ...turn })).rejects.toMatchObject({ status: 400 });
writeFileSync(join(DESIGNS, "bundle.zip"), "PK");
await expect(saveBotAttachment({ path: join(DESIGNS, "bundle.zip"), ...turn })).rejects.toMatchObject({ status: 415 });
const big = join(DESIGNS, "big.mp4");
writeFileSync(big, "x");
truncateSync(big, FILE_MAX_BYTES + 1);
await expect(saveBotAttachment({ path: big, ...turn })).rejects.toMatchObject({ status: 413 });
await expect(saveBotAttachment({ path: "nope.pdf", ...turn })).rejects.toMatchObject({ status: 404 });
await expect(saveBotAttachment({ path: join(DESIGNS, "nope.pdf"), ...turn })).rejects.toMatchObject({ status: 404 });
});
});
describe("attachForTurn", () => {
/** A copy the test finishes by hand, so timing is never a guess. */
const deferred = <T>() => {
let resolve!: (value: T) => void;
let reject!: (reason: unknown) => void;
const promise = new Promise<T>((res, rej) => { resolve = res; reject = rej; });
return { promise, resolve, reject };
};
const hooks = (over: Partial<Parameters<typeof attachForTurn<string>>[2]> = {}) => {
const published: string[] = [];
const discarded: string[] = [];
return {
published,
discarded,
hooks: {
save: async () => "saved",
stillLive: () => true,
publish: (saved: string) => { published.push(saved); },
discard: (saved: string) => { discarded.push(saved); },
...over,
},
};
};
it("holds the cap under parallel calls that are all still copying", async () => {
const turn: { attachedFiles?: number } = {};
const copies = Array.from({ length: 6 }, () => deferred<string>());
let started = 0;
const seen = hooks();
const calls = copies.map((copy, index) => attachForTurn(turn, 3, {
...seen.hooks,
save: () => { started += 1; return copy.promise.then(() => `file-${index}`); },
}));
expect(started).toBe(3);
expect(await Promise.all(calls.slice(3))).toEqual([{ status: "limit" }, { status: "limit" }, { status: "limit" }]);
copies.slice(0, 3).forEach((copy) => copy.resolve("done"));
const settled = await Promise.all(calls.slice(0, 3));
expect(settled.map((outcome) => outcome.status)).toEqual(["attached", "attached", "attached"]);
expect(seen.published).toHaveLength(3);
expect(turn.attachedFiles).toBe(3);
expect(await attachForTurn(turn, 3, seen.hooks)).toEqual({ status: "limit" });
});
it("gives the slot back when a copy fails, so a bad path does not use up the turn's budget", async () => {
const turn: { attachedFiles?: number } = {};
const seen = hooks({ save: async () => { throw Object.assign(new Error("not found"), { status: 404 }); } });
for (let attempt = 0; attempt < 5; attempt += 1) {
await expect(attachForTurn(turn, 2, seen.hooks)).rejects.toMatchObject({ status: 404 });
}
expect(turn.attachedFiles).toBe(0);
expect(seen.discarded).toEqual([]);
expect((await attachForTurn(turn, 2, hooks().hooks)).status).toBe("attached");
});
it("posts nothing and removes the copy when the turn ends while the file is being copied", async () => {
const turn: { attachedFiles?: number } = {};
const copy = deferred<string>();
let live = true;
const seen = hooks({ save: () => copy.promise, stillLive: () => live });
const call = attachForTurn(turn, 10, seen.hooks);
live = false; // stopped, replaced, deleted or moved mid-copy
copy.resolve("half-second-later.pdf");
expect(await call).toEqual({ status: "ended" });
expect(seen.published).toEqual([]);
expect(seen.discarded).toEqual(["half-second-later.pdf"]);
expect(turn.attachedFiles).toBe(0);
});
it("removes the copy when publishing itself fails", async () => {
const turn: { attachedFiles?: number } = {};
const seen = hooks({ publish: () => { throw new Error("thread is gone"); } });
await expect(attachForTurn(turn, 10, seen.hooks)).rejects.toThrow("thread is gone");
expect(seen.discarded).toEqual(["saved"]);
expect(turn.attachedFiles).toBe(0);
});
it("does not discard a copy it published", async () => {
const seen = hooks();
expect(await attachForTurn({}, 10, seen.hooks)).toEqual({ status: "attached", saved: "saved" });
expect(seen.discarded).toEqual([]);
});
});
describe("deliveredCopy", () => {
type Message = Parameters<typeof deliveredCopy>[1];
const bot = (id: string, parentId: string | null, extra: Partial<Message> = {}): Message =>
({ id, at: 1, parentId, role: "bot", kind: "text", text: "", ...extra }) as Message;
const photo = { kind: "image" as const, path: "/store/copy.png", mime: "image/png", name: "sideboard.png" };
const ask = { id: "ask", at: 1, parentId: null, role: "user", kind: "text", text: "find a sideboard" } as Message;
it("finds the picture the reply's turn delivered under the name its inline image uses", () => {
const attached = bot("attached", "ask", { attachments: [photo] });
const tool = bot("tool", "attached", { kind: "activity" });
const reply = bot("reply", "tool", { text: "" });
expect(deliveredCopy([ask, attached, tool, reply], reply, "sideboard.png")).toEqual(photo);
expect(deliveredCopy([ask, attached, tool, reply], reply, "other.png")).toBeNull();
});
it("stops at the person's message, so an earlier turn's delivery is not this reply's", () => {
const attached = bot("attached", null, { attachments: [photo] });
const again = { ...ask, parentId: "attached" };
const reply = bot("reply", "ask");
expect(deliveredCopy([attached, again, reply], reply, "sideboard.png")).toBeNull();
});
it("counts only the reply's author, and two deliveries with one name match neither", () => {
const by = (botId: string) => ({ from: { botId, name: botId, color: "blue" } }) as Partial<Message>;
const peer = bot("peer", "ask", { ...by("other"), attachments: [photo] });
const reply = bot("reply", "peer", by("me"));
expect(deliveredCopy([ask, peer, reply], reply, "sideboard.png")).toBeNull();
const first = bot("first", "ask", { ...by("me"), attachments: [photo] });
const second = bot("second", "first", { ...by("me"), attachments: [{ ...photo, path: "/store/copy-2.png" }] });
const twice = bot("twice", "second", by("me"));
expect(deliveredCopy([ask, first, second, twice], twice, "sideboard.png")).toBeNull();
expect(deliveredCopy([ask, first, twice], { ...twice, parentId: "first" }, "sideboard.png")).toEqual(photo);
});
});