evOo-local/packages/testkit/src/artifact-routes.test.ts
Claude 4d75144cb8
feat(media): give agents attached media and larger bot files
Agents only received the documents of a turn on their computer and could
attach workspace files up to 10 MiB, so they could neither cut a clip a user
sent nor post the video they rendered.

- Current-turn images, video and audio are copied into the bot's workspace
  (Team Computers under bots/<botId>/) like documents. Remote computers move
  files as base64 JSON, so anything above their 12 MiB transfer cap stays in
  storage and the agent is told it by artifact id instead of failing the
  turn; local computers copy up to the kind cap.
- attach_file accepts video and audio by extension and attaches up to the
  kind cap when the store streams (still bounded by the transfer cap of a
  remote computer), stored through the shared artifact helper with a real
  SHA-256.
- The render_plot fallback that attaches a chart PNG now records the group,
  so the chart opens in group conversations.
- Agent history names image artifacts by id, like video and audio, so a
  later turn can refer to them.
- artifacts.get refuses files above 25 MiB: base64 reads hold the whole
  file in memory, and larger media is served by the artifact route.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MRtGG9ARfqqhma3t85Ro2u
2026-10-03 13:53:48 +00:00

375 lines
14 KiB
TypeScript

import { mkdtempSync, readdirSync } from "node:fs";
import { tmpdir } from "node:os";
import path from "node:path";
import type { PrismaClient } from "@rakazo/db";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import { sessionCookieHeader } from "./index.js";
type App = { request: (input: string | Request, init?: RequestInit) => Promise<Response> };
process.env.WAKEUP_DRIVER = "memory";
process.env.SANDBOX_PROVIDER = "fake";
process.env.AGENT_RUNTIME = "scripted";
const hasDb = process.env.VERIFY_DATABASE === "1" && Boolean(process.env.DATABASE_URL);
const describeRoutes = hasDb ? describe : describe.skip;
const ORIGIN = "http://127.0.0.1:5173";
describeRoutes("artifact HTTP routes", () => {
let app: App;
let prisma: PrismaClient;
let stop: () => Promise<void>;
const stamp = Date.now();
const dataDir = mkdtempSync(path.join(tmpdir(), "rakazo-artifact-routes-"));
const video = Buffer.from("0123456789abcdefghij");
beforeAll(async () => {
const { createApp } = await import("../../../apps/api/src/app.ts");
const handles = await createApp({
databaseUrl: process.env.DATABASE_URL!,
dataDir,
sandboxProvider: "fake",
agentRuntime: "scripted",
});
app = handles.app;
prisma = handles.prisma;
stop = handles.stop;
});
afterAll(async () => {
await stop?.();
});
it("uploads by stream and serves full, ranged, cached and download responses", async () => {
const owner = await signup(app, `artifact-route-${stamp}@rakazo.test`, "Route Owner");
const bot = await rpc<{ id: string }>(app, owner, "bots/create", botInput("Streamer"));
const uploaded = await upload(app, owner, { botId: bot.id }, "clip.mp4", "video/mp4", video);
expect(uploaded.status).toBe(201);
const artifact = (await uploaded.json()) as { id: string; size: number; groupId: null };
expect(artifact).toMatchObject({ size: video.length, groupId: null, mimeType: "video/mp4" });
const row = await prisma.artifact.findUniqueOrThrow({ where: { id: artifact.id } });
expect(row).toMatchObject({ botId: bot.id, groupId: null, runId: null, name: "clip.mp4" });
expect(row.hash).toMatch(/^[0-9a-f]{64}$/);
const full = await get(app, owner, artifact.id);
expect(full.status).toBe(200);
expect(full.headers.get("content-type")).toBe("video/mp4");
expect(full.headers.get("accept-ranges")).toBe("bytes");
expect(full.headers.get("content-length")).toBe(String(video.length));
expect(full.headers.get("content-disposition")).toMatch(/^inline; filename="clip\.mp4"/);
expect(full.headers.get("x-content-type-options")).toBe("nosniff");
expect(full.headers.get("content-security-policy")).toContain("media-src 'self' blob:");
expect(Buffer.from(await full.arrayBuffer())).toEqual(video);
const ranged = await get(app, owner, artifact.id, { range: "bytes=2-5" });
expect(ranged.status).toBe(206);
expect(ranged.headers.get("content-range")).toBe(`bytes 2-5/${video.length}`);
expect(ranged.headers.get("content-length")).toBe("4");
expect(await ranged.text()).toBe("2345");
const suffix = await get(app, owner, artifact.id, { range: "bytes=-3" });
expect(suffix.status).toBe(206);
expect(await suffix.text()).toBe("hij");
for (const range of [`bytes=${video.length}-`, "bytes=0-1,4-5", "bytes=x-y"]) {
const rejected = await get(app, owner, artifact.id, { range });
expect(rejected.status, range).toBe(416);
expect(rejected.headers.get("content-range")).toBe(`bytes */${video.length}`);
}
const etag = full.headers.get("etag")!;
expect(etag).toBe(`"${row.hash}"`);
expect((await get(app, owner, artifact.id, { "if-none-match": etag })).status).toBe(304);
const staleIfRange = await get(app, owner, artifact.id, {
range: "bytes=0-1",
"if-range": '"other"',
});
expect(staleIfRange.status).toBe(200);
expect(Buffer.from(await staleIfRange.arrayBuffer())).toEqual(video);
const head = await app.request(`/api/artifacts/${artifact.id}`, {
method: "HEAD",
headers: { cookie: owner },
});
expect(head.status).toBe(200);
expect(head.headers.get("content-length")).toBe(String(video.length));
const download = await app.request(`/api/artifacts/${artifact.id}?download=1`, {
headers: { cookie: owner },
});
expect(download.headers.get("content-disposition")).toMatch(/^attachment; /);
await download.body?.cancel();
// Artifacts created through the inline RPC are served the same way; files download.
const notes = await rpc<{ id: string }>(app, owner, "artifacts/create", {
botId: bot.id,
name: "notes.txt",
mimeType: "text/plain",
contentBase64: Buffer.from("hello notes").toString("base64"),
});
const notesResponse = await get(app, owner, notes.id);
expect(notesResponse.headers.get("content-disposition")).toMatch(/^attachment; /);
expect(await notesResponse.text()).toBe("hello notes");
});
it("denies other users, other spaces, archived threads and signed-out requests", async () => {
const owner = await signup(app, `artifact-owner-${stamp}@rakazo.test`, "Artifact Owner");
const intruder = await signup(app, `artifact-intruder-${stamp}@rakazo.test`, "Intruder");
const bot = await rpc<{ id: string }>(app, owner, "bots/create", botInput("Owner Bot"));
const bot2 = await rpc<{ id: string }>(app, owner, "bots/create", botInput("Owner Bot 2"));
const group = await rpc<{ id: string }>(app, owner, "groups/create", {
name: "Owner Group",
botIds: [bot.id, bot2.id],
});
const botArtifact = (await (
await upload(app, owner, { botId: bot.id }, "a.mp3", "audio/mpeg", video)
).json()) as { id: string };
const groupUpload = await upload(
app,
owner,
{ groupId: group.id },
"g.png",
"image/png",
video,
);
expect(groupUpload.status).toBe(201);
const groupArtifact = (await groupUpload.json()) as { id: string; groupId: string };
expect(groupArtifact.groupId).toBe(group.id);
expect((await get(app, owner, groupArtifact.id)).status).toBe(200);
// Another user: reads and uploads into the owner's threads look like missing resources.
expect((await get(app, intruder, botArtifact.id)).status).toBe(404);
expect((await get(app, intruder, groupArtifact.id)).status).toBe(404);
expect(
(await upload(app, intruder, { botId: bot.id }, "x.mp4", "video/mp4", video)).status,
).toBe(404);
expect(
(await upload(app, intruder, { groupId: group.id }, "x.mp4", "video/mp4", video)).status,
).toBe(404);
// Signed out.
expect((await app.request(`/api/artifacts/${botArtifact.id}`)).status).toBe(401);
expect(
(
await app.request(`/api/artifacts?botId=${bot.id}&name=x.mp4`, {
method: "POST",
headers: { "content-type": "video/mp4" },
body: video,
})
).status,
).toBe(401);
// An explicit space header must name the artifact's space, like the RPC.
const other = await rpc<{ id: string }>(app, owner, "spaces/create", { name: "Other space" });
expect((await get(app, owner, botArtifact.id, { "x-rakazo-space-id": other.id })).status).toBe(
404,
);
expect(
(
await upload(app, owner, { botId: bot.id }, "x.mp4", "video/mp4", video, {
"x-rakazo-space-id": other.id,
})
).status,
).toBe(404);
// Archived threads stop serving their artifacts, as artifacts.get does.
await rpc(app, owner, "groups/archive", { groupId: group.id });
expect((await get(app, owner, groupArtifact.id)).status).toBe(404);
await rpc(app, owner, "bots/archive", { botId: bot.id });
expect((await get(app, owner, botArtifact.id)).status).toBe(404);
});
it("keeps a shared-space member out of another member's artifacts", async () => {
const owner = await signup(app, `artifact-shared-owner-${stamp}@rakazo.test`, "Shared Owner");
const member = await signup(app, `artifact-shared-member-${stamp}@rakazo.test`, "Member");
const shared = await rpc<{ id: string }>(app, owner, "spaces/create", { name: "Shared" });
const sharedRow = await prisma.space.findUniqueOrThrow({
where: { id: shared.id },
select: { organizationId: true },
});
const memberActor = await rpc<{ userId: string }>(app, member, "me");
await prisma.member.deleteMany({ where: { userId: memberActor.userId } });
await prisma.member.create({
data: {
id: `artifact-shared-org-${stamp}`,
organizationId: sharedRow.organizationId,
userId: memberActor.userId,
role: "member",
createdAt: new Date(),
},
});
await prisma.spaceMember.create({
data: {
id: `artifact-shared-member-${stamp}`,
spaceId: shared.id,
organizationId: sharedRow.organizationId,
userId: memberActor.userId,
role: "member",
createdAt: new Date(),
},
});
const ownerBot = await rpc<{ id: string }>(
app,
owner,
"bots/create",
botInput("Shared Owner Bot"),
shared.id,
);
const artifact = (await (
await upload(app, owner, { botId: ownerBot.id }, "s.webm", "video/webm", video, {
"x-rakazo-space-id": shared.id,
})
).json()) as { id: string };
expect((await get(app, owner, artifact.id)).status).toBe(200);
expect((await get(app, member, artifact.id)).status).toBe(404);
expect((await get(app, member, artifact.id, { "x-rakazo-space-id": shared.id })).status).toBe(
404,
);
expect(
(
await upload(app, member, { botId: ownerBot.id }, "x.mp4", "video/mp4", video, {
"x-rakazo-space-id": shared.id,
})
).status,
).toBe(404);
});
it("enforces type and size limits on uploads, including chunked bodies", async () => {
const owner = await signup(app, `artifact-limits-${stamp}@rakazo.test`, "Limits");
const bot = await rpc<{ id: string }>(app, owner, "bots/create", botInput("Limits Bot"));
const before = await prisma.artifact.count({ where: { botId: bot.id } });
expect((await upload(app, owner, { botId: bot.id }, "a.html", "text/html", video)).status).toBe(
415,
);
expect((await upload(app, owner, { botId: bot.id }, "", "video/mp4", video)).status).toBe(400);
expect(
(await upload(app, owner, { botId: bot.id }, "e.mp4", "video/mp4", Buffer.alloc(0))).status,
).toBe(400);
const declared = await app.request(
new Request(`${ORIGIN}/api/artifacts?botId=${bot.id}&name=big.mp4`, {
method: "POST",
headers: {
cookie: owner,
"content-type": "video/mp4",
"content-length": String(500 * 1024 * 1024 + 1),
},
body: new ReadableStream({ start: (controller) => controller.close() }),
duplex: "half",
} as RequestInit & { duplex: "half" }),
);
expect(declared.status).toBe(413);
// No Content-Length: the 25 MiB file cap is enforced while streaming to disk.
const mib = new Uint8Array(1024 * 1024);
let sent = 0;
const chunked = await app.request(
new Request(`${ORIGIN}/api/artifacts?botId=${bot.id}&name=big.txt`, {
method: "POST",
headers: { cookie: owner, "content-type": "text/plain" },
body: new ReadableStream({
pull(controller) {
if (sent++ > 26) controller.close();
else controller.enqueue(mib);
},
}),
duplex: "half",
} as RequestInit & { duplex: "half" }),
);
expect(chunked.status).toBe(413);
expect(await prisma.artifact.count({ where: { botId: bot.id } })).toBe(before);
const me = await rpc<{ spaceId: string }>(app, owner, "me");
const spaceDir = path.join(dataDir, "artifacts", me.spaceId);
expect(readdirSafe(spaceDir)).toEqual([]);
// Video far above the inline cap uploads, streams by range, and is refused as base64.
const large = Buffer.alloc(26 * 1024 * 1024, 1);
large.write("head", 0);
const uploaded = await upload(app, owner, { botId: bot.id }, "long.mp4", "video/mp4", large);
expect(uploaded.status).toBe(201);
const largeVideo = (await uploaded.json()) as { id: string };
const head = await get(app, owner, largeVideo.id, { range: "bytes=0-3" });
expect(head.status).toBe(206);
expect(await head.text()).toBe("head");
await expect(
rpc(app, owner, "artifacts/get", { botId: bot.id, artifactId: largeVideo.id }),
).rejects.toThrow(/400: .*too large to load inline/);
});
});
function readdirSafe(dir: string) {
try {
return readdirSync(dir);
} catch {
return [];
}
}
function botInput(name: string) {
return {
name,
title: name,
description: "artifact routes",
instructions: "",
notifyOnFinish: true,
};
}
async function upload(
app: App,
cookie: string,
target: { botId: string } | { groupId: string },
name: string,
mimeType: string,
bytes: Buffer,
headers: Record<string, string> = {},
) {
const query = new URLSearchParams({ ...target, name });
return app.request(`/api/artifacts?${query}`, {
method: "POST",
headers: { cookie, origin: ORIGIN, "content-type": mimeType, ...headers },
body: bytes,
});
}
function get(app: App, cookie: string, id: string, headers: Record<string, string> = {}) {
return app.request(`/api/artifacts/${id}`, { headers: { cookie, ...headers } });
}
async function signup(app: App, email: string, name: string) {
const response = await app.request("/api/auth/sign-up/email", {
method: "POST",
headers: { "content-type": "application/json", origin: ORIGIN },
body: JSON.stringify({ email, password: "test-password-123", name }),
});
expect(response.status).toBeLessThan(400);
return sessionCookieHeader(response);
}
async function rpc<T>(
app: App,
cookie: string,
procedure: string,
body: unknown = {},
spaceId?: string,
): Promise<T> {
const response = await app.request(`/rpc/${procedure}`, {
method: "POST",
headers: {
"content-type": "application/json",
cookie,
origin: ORIGIN,
...(spaceId ? { "x-rakazo-space-id": spaceId } : {}),
},
body: JSON.stringify({ json: body }),
});
const text = await response.text();
const parsed = JSON.parse(text) as { json?: T; error?: { message?: string } };
if (response.status >= 400 || parsed.error) {
throw new Error(`${procedure} ${response.status}: ${parsed.error?.message ?? text}`);
}
return parsed.json as T;
}