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 = () => { let resolve!: (value: T) => void; let reject!: (reason: unknown) => void; const promise = new Promise((res, rej) => { resolve = res; reject = rej; }); return { promise, resolve, reject }; }; const hooks = (over: Partial>[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()); 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(); 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[1]; const bot = (id: string, parentId: string | null, extra: Partial = {}): 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: "![Sideboard](/tmp/sideboard.png)" }); 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; 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); }); });