feat(web,server,core): attach files to a message from the composer (#121)

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Yaowei Zheng
2026-07-29 23:44:10 +08:00
committed by GitHub
parent 37fc715ce9
commit 1d23a7acaf
25 changed files with 1692 additions and 131 deletions
+142
View File
@@ -0,0 +1,142 @@
/**
* Global request body cap (`/api/*`, 20MB).
*
* The cap used to read `content-length` only, which a chunked request simply does not carry —
* `Number(undefined ?? 0)` is 0, so a body of any size passed straight through to the sinks
* behind it (task input images, file attachments, Trace import). These tests post a body with
* **no declared length** and require the same 413 `payload_too_large` a declared one gets, plus
* an under-cap streamed body still arriving intact (the cap has to re-feed what it counted).
*/
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { assistantText } from "@prismshadow/penguin-core";
import type { OmniMessage } from "@prismshadow/penguin-core";
import type { SessionRow } from "../src/db/repos/sessions.js";
import type { RuntimeSession } from "../src/runtime/session-manager.js";
import { apiClient, createTestApp, provisionUser, waitFor } from "./helpers.js";
import type { TestApp } from "./helpers.js";
const SID = "session-2026-07-29-13-00-00-aabb0004";
const PROJECT_ID = "streamer-default_project";
const MB = 1024 * 1024;
/**
* A `{"input":[{"type":"text","text":"aaa…"}]}` body delivered as a stream with no
* `content-length`, `fill` bytes of filler inside the text. Valid JSON on purpose: if the cap
* ever stops working the request is a plain 202, exactly the shape the review reproduced —
* not a 400 that would pass a "was rejected" assertion for the wrong reason. Chunks are
* produced on demand, so the cap aborting mid-body costs only what it actually read.
*/
function streamedTaskBody(fill: number): ReadableStream<Uint8Array> {
const enc = new TextEncoder();
const chunk = enc.encode("a".repeat(64 * 1024));
let sent = 0;
let tailWritten = false;
return new ReadableStream({
start(controller) {
controller.enqueue(enc.encode('{"input":[{"type":"text","text":"'));
},
pull(controller) {
if (sent >= fill) {
if (tailWritten) {
controller.close();
return;
}
tailWritten = true;
controller.enqueue(enc.encode('"}]}'));
return;
}
const size = Math.min(chunk.length, fill - sent);
sent += size;
controller.enqueue(size === chunk.length ? chunk : chunk.subarray(0, size));
},
});
}
describe("request body cap", () => {
let t: TestApp;
let api: ReturnType<typeof apiClient>;
let cookie: string;
let runs: OmniMessage[][];
const postStream = (fill: number) =>
t.app.request(`/api/sessions/${SID}/tasks`, {
method: "POST",
headers: { cookie, "content-type": "application/json" },
body: streamedTaskBody(fill),
// Required by fetch for a streaming request body; it is also what keeps the request
// free of a content-length header, which is the case under test.
duplex: "half",
} as RequestInit);
beforeEach(async () => {
t = await createTestApp();
({ cookie } = await provisionUser(t.app, "streamer"));
api = apiClient(t.app, cookie);
const row: SessionRow = {
sessionId: SID,
projectId: PROJECT_ID,
agentId: "default_agent",
provider: "custom",
modelId: "m1",
workspace: "/tmp/w",
approvalMode: "allow-all",
title: null,
createdAt: new Date().toISOString(),
};
t.deps.sessionsRepo.insert(row);
runs = [];
const session: RuntimeSession = {
sessionId: SID,
toolPermission: () => "rw",
generateTitle: async () => ({ title: null, usage: null }),
compactability: () => "ok" as const,
steer: () => false,
skipReconnectWait: () => false,
async *run(input: OmniMessage[]) {
runs.push(input);
yield assistantText("done");
},
async *compact() {},
};
t.deps.manager.adopt(row, session);
});
afterEach(async () => {
await t.cleanup();
});
it("a body with no declared length is still capped", async () => {
const res = await postStream(24 * MB);
expect(res.status).toBe(413);
expect(((await res.json()) as { error: { code: string } }).error.code).toBe(
"payload_too_large",
);
expect(runs).toHaveLength(0);
});
it("a declared over-cap content-length short-circuits before the body is read", async () => {
// The header fast path, which the streaming case above deliberately cannot reach: the
// length is declared and the (tiny, valid) body is never looked at.
const res = await t.app.request(`/api/sessions/${SID}/tasks`, {
method: "POST",
headers: {
cookie,
"content-type": "application/json",
"content-length": String(21 * MB),
},
body: JSON.stringify({ input: [{ type: "text", text: "small" }] }),
});
expect(res.status).toBe(413);
expect(((await res.json()) as { error: { code: string } }).error.code).toBe(
"payload_too_large",
);
expect(runs).toHaveLength(0);
});
it("an under-cap streamed body is passed through intact", async () => {
const res = await postStream(MB);
expect(res.status).toBe(202);
await waitFor(() => runs.length === 1);
const text = (runs[0]![0]!.payload as { text: string }).text;
expect(text.length).toBe(MB);
});
});
@@ -88,6 +88,16 @@ describe("session deletion cleans up the scratchpad", () => {
expect(res.headers.get("content-type")).toBe("image/png");
expect(Buffer.from(await res.arrayBuffer())).toEqual(png);
// A non-ASCII attachment name round-trips: the composer percent-encodes it into the URL,
// and the route contains the read by resolving the path rather than by whitelisting
// characters — so `报告.pdf` stays fetchable instead of having to be renamed on upload.
await fs.writeFile(path.join(dir, "报告.pdf"), "cjk");
const cjk = await owner.get(
`/api/sessions/${session.sessionId}/scratchpad/${encodeURIComponent("报告.pdf")}`,
);
expect(cjk.status).toBe(200);
expect(await cjk.text()).toBe("cjk");
// Missing files and filenames with path separators/traversal both 404 (no existence leak).
expect((await owner.get(`/api/sessions/${session.sessionId}/scratchpad/nope.png`)).status).toBe(
404,
@@ -95,5 +105,10 @@ describe("session deletion cleans up the scratchpad", () => {
expect(
(await owner.get(`/api/sessions/${session.sessionId}/scratchpad/..%2Fsecret.png`)).status,
).toBe(404);
// Backslash separators and a bare relative marker are rejected the same way.
expect(
(await owner.get(`/api/sessions/${session.sessionId}/scratchpad/..%5Csecret.png`)).status,
).toBe(404);
expect((await owner.get(`/api/sessions/${session.sessionId}/scratchpad/..`)).status).toBe(404);
});
});
@@ -0,0 +1,456 @@
/**
* Integration tests for composer file attachments (POST /api/sessions/:id/tasks with a
* `{type:"file"}` input part):
* - the bytes land in the Session scratchpad and the Prompt gains an
* `[attached file: <path>]` line, so the model reaches the file by path;
* - a files-only Prompt still reaches the model (the lines become the message), including
* when the only text message is a `[handoff_from]` origin block that must stay parseable;
* - two uploads of the same name coexist instead of overwriting each other;
* - malformed parts are 400s, an oversize file / too many files / too many bytes are 413s,
* and goal mode rejects attachments before anything is written;
* - nothing survives a request that does not end up starting a Task, and a scratchpad
* directory that resolves outside the Agent's scratchpad root is refused outright.
*/
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import {
assistantText,
buildHandoffMessage,
parseHandoffMessage,
scratchpadDir,
} from "@prismshadow/penguin-core";
import type { OmniMessage } from "@prismshadow/penguin-core";
import type { SessionRow } from "../src/db/repos/sessions.js";
import type { RuntimeSession } from "../src/runtime/session-manager.js";
import {
MAX_ATTACHMENT_BYTES,
MAX_ATTACHMENT_COUNT,
MAX_TOTAL_ATTACHMENT_BYTES,
} from "../src/services/task-attachments.js";
import { apiClient, createTestApp, provisionUser, waitFor } from "./helpers.js";
import type { TestApp } from "./helpers.js";
const SID = "session-2026-07-29-10-00-00-aabb0001";
const PROJECT_ID = "attacher-default_project";
/** Fake Session that records each run's input and finishes immediately (no LLM, no approvals). */
function recordingFakeSession(sessionId: string, runs: OmniMessage[][]): RuntimeSession {
return {
sessionId,
toolPermission: () => "rw",
generateTitle: async () => ({ title: null, usage: null }),
compactability: () => "ok" as const,
steer: () => false,
skipReconnectWait: () => false,
async *run(input: OmniMessage[]) {
runs.push(input);
yield assistantText("done");
},
async *compact() {},
};
}
/** Fake Session whose run parks until `until` resolves, so the Session stays busy while the test posts. */
function parkingFakeSession(sessionId: string, until: Promise<void>): RuntimeSession {
return {
...recordingFakeSession(sessionId, []),
async *run() {
await until;
yield assistantText("done");
},
};
}
/** Base64 data URL of some bytes, the shape the composer submits. */
function dataUrl(content: string, mime = "application/octet-stream"): string {
return `data:${mime};base64,${Buffer.from(content).toString("base64")}`;
}
/** All text of a recorded Prompt, joined the way the model would read it. */
function promptText(input: OmniMessage[]): string {
return input
.map((m) => (m.payload as { text?: string }).text ?? "")
.filter(Boolean)
.join("\n");
}
describe("task input file attachments", () => {
let t: TestApp;
let api: ReturnType<typeof apiClient>;
let runs: OmniMessage[][];
let dir: string;
let row: SessionRow;
beforeEach(async () => {
t = await createTestApp();
const { cookie } = await provisionUser(t.app, "attacher");
api = apiClient(t.app, cookie);
row = {
sessionId: SID,
projectId: PROJECT_ID,
agentId: "default_agent",
provider: "custom",
modelId: "m1",
workspace: "/tmp/w",
approvalMode: "allow-all",
title: null,
createdAt: new Date().toISOString(),
};
t.deps.sessionsRepo.insert(row);
runs = [];
t.deps.manager.adopt(row, recordingFakeSession(SID, runs));
dir = path.join(scratchpadDir(t.root, PROJECT_ID, "default_agent"), SID);
});
afterEach(async () => {
await t.cleanup();
});
it("writes the file into the session scratchpad and appends the marker line to the text", async () => {
const res = await api.post(`/api/sessions/${SID}/tasks`, {
input: [
{ type: "text", text: "look at this" },
{ type: "file", fileName: "report.pdf", dataUrl: dataUrl("PDF-BYTES") },
],
});
expect(res.status).toBe(202);
await waitFor(() => runs.length === 1);
const text = promptText(runs[0]!);
const marker = /\[attached file: (.+)\]/.exec(text);
expect(marker).not.toBeNull();
const filePath = marker![1]!;
expect(filePath).toBe(path.join(dir, "report.pdf"));
expect(await fs.readFile(filePath, "utf8")).toBe("PDF-BYTES");
// The line trails the user's own text — it must not replace or reframe the message.
expect(text.startsWith("look at this")).toBe(true);
// One text message carries both (no extra message per file).
expect(runs[0]!).toHaveLength(1);
});
it("files-only input becomes a message of attachment lines; same names do not overwrite", async () => {
const res = await api.post(`/api/sessions/${SID}/tasks`, {
input: [
{ type: "file", fileName: "notes.txt", dataUrl: dataUrl("first") },
{ type: "file", fileName: "notes.txt", dataUrl: dataUrl("second") },
],
});
expect(res.status).toBe(202);
await waitFor(() => runs.length === 1);
const paths = [...promptText(runs[0]!).matchAll(/\[attached file: (.+)\]/g)].map((m) => m[1]!);
expect(paths).toHaveLength(2);
expect(paths[0]).toBe(path.join(dir, "notes.txt"));
// The second upload gets a random suffix rather than clobbering the first.
expect(paths[1]).not.toBe(paths[0]);
expect(path.basename(paths[1]!)).toMatch(/^notes-[0-9a-f]{6}\.txt$/);
expect(await fs.readFile(paths[0]!, "utf8")).toBe("first");
expect(await fs.readFile(paths[1]!, "utf8")).toBe("second");
});
it("unsafe characters in the name are sanitized, keeping the extension", async () => {
const res = await api.post(`/api/sessions/${SID}/tasks`, {
input: [{ type: "file", fileName: "my report (final).csv", dataUrl: dataUrl("a,b") }],
});
expect(res.status).toBe(202);
await waitFor(() => runs.length === 1);
const filePath = /\[attached file: (.+)\]/.exec(promptText(runs[0]!))![1]!;
expect(path.basename(filePath)).toBe("my-report--final-.csv");
expect(await fs.readFile(filePath, "utf8")).toBe("a,b");
});
it("keeps a non-ASCII name instead of flattening it, and caps the stem by UTF-8 bytes", async () => {
// A CJK character costs three bytes: 40 of them are 120 bytes, well past the 80-byte cap,
// so the name is cut on a character boundary rather than mid-character (a split would leave
// an invalid sequence on disk and an unopenable path in the message).
const long = "报".repeat(40);
const res = await api.post(`/api/sessions/${SID}/tasks`, {
input: [
{ type: "file", fileName: "报告 2026.pdf", dataUrl: dataUrl("cjk") },
{ type: "file", fileName: `${long}.txt`, dataUrl: dataUrl("long") },
],
});
expect(res.status).toBe(202);
await waitFor(() => runs.length === 1);
const paths = [...promptText(runs[0]!).matchAll(/\[attached file: (.+)\]/g)].map((m) => m[1]!);
// The words survive; only the space (shell-hostile, and ASCII) is replaced.
expect(path.basename(paths[0]!)).toBe("报告-2026.pdf");
expect(await fs.readFile(paths[0]!, "utf8")).toBe("cjk");
const capped = path.basename(paths[1]!);
expect(capped).toBe(`${"报".repeat(26)}.txt`);
expect(Buffer.byteLength(capped.slice(0, capped.length - 4))).toBeLessThanOrEqual(80);
expect(await fs.readFile(paths[1]!, "utf8")).toBe("long");
});
it("prefixes a Windows device name and falls back when the stem sanitizes away", async () => {
const res = await api.post(`/api/sessions/${SID}/tasks`, {
input: [
{ type: "file", fileName: "con.txt", dataUrl: dataUrl("device") },
// Zero-width joiner only (spelled by code point — an invisible character in the source
// would read as an empty string): category C, so nothing is left to name the file with.
{
type: "file",
fileName: `${String.fromCodePoint(0x200d)}.bin`,
dataUrl: dataUrl("invisible"),
},
],
});
expect(res.status).toBe(202);
await waitFor(() => runs.length === 1);
const paths = [...promptText(runs[0]!).matchAll(/\[attached file: (.+)\]/g)].map((m) => m[1]!);
expect(path.basename(paths[0]!)).toBe("_con.txt");
expect(path.basename(paths[1]!)).toBe("file.bin");
});
it("accepts a data URL whose media type carries parameters", async () => {
const res = await api.post(`/api/sessions/${SID}/tasks`, {
input: [
{
type: "file",
fileName: "notes.txt",
dataUrl: dataUrl("hello", "text/plain;charset=utf-8"),
},
],
});
expect(res.status).toBe(202);
await waitFor(() => runs.length === 1);
const filePath = /\[attached file: (.+)\]/.exec(promptText(runs[0]!))![1]!;
expect(await fs.readFile(filePath, "utf8")).toBe("hello");
});
it("malformed parts are 400s and write nothing", async () => {
const bad = [
{ type: "file", dataUrl: dataUrl("x") }, // no fileName
{ type: "file", fileName: "", dataUrl: dataUrl("x") },
{ type: "file", fileName: "../escape.txt", dataUrl: dataUrl("x") },
{ type: "file", fileName: "sub/dir.txt", dataUrl: dataUrl("x") },
{ type: "file", fileName: "a.txt", dataUrl: "https://example.com/a.txt" },
{ type: "file", fileName: "a.txt", dataUrl: "data:text/plain,not-base64" },
{ type: "file", fileName: "a.txt" }, // no dataUrl
{ type: "blob", fileName: "a.txt", dataUrl: dataUrl("x") }, // unknown part type
];
for (const part of bad) {
const res = await api.post(`/api/sessions/${SID}/tasks`, { input: [part] });
expect(res.status, JSON.stringify(part)).toBe(400);
}
await expect(fs.access(dir)).rejects.toThrow();
expect(runs).toHaveLength(0);
});
it("a file over the per-file cap is a 413", async () => {
const res = await api.post(`/api/sessions/${SID}/tasks`, {
input: [
{
type: "file",
fileName: "big.bin",
dataUrl: `data:application/octet-stream;base64,${Buffer.alloc(
MAX_ATTACHMENT_BYTES + 1,
).toString("base64")}`,
},
],
});
expect(res.status).toBe(413);
expect(((await res.json()) as { error: { code: string } }).error.code).toBe("file_too_large");
await expect(fs.access(dir)).rejects.toThrow();
});
it("goal mode rejects attachments before anything is written", async () => {
const res = await api.post(`/api/sessions/${SID}/tasks`, {
input: [
{ type: "text", text: "ship the report" },
{ type: "file", fileName: "spec.md", dataUrl: dataUrl("# spec") },
],
goal: {},
});
expect(res.status).toBe(400);
await expect(fs.access(dir)).rejects.toThrow();
});
it("files with only a handoff origin block: the block stays parseable, the lines get their own message", async () => {
// The composer's "attachments, no text, staged /agent handoff" shape. `[handoff_from]` only
// parses when the block is the WHOLE message, so appending the marker line to it would put
// the raw block in a user bubble instead of a one-line banner.
const block = buildHandoffMessage({ agentId: "alpha", agentName: "Alpha", sessionId: "s0" });
const res = await api.post(`/api/sessions/${SID}/tasks`, {
input: [
{ type: "text", text: block },
{ type: "file", fileName: "notes.txt", dataUrl: dataUrl("hi") },
],
});
expect(res.status).toBe(202);
await waitFor(() => runs.length === 1);
const texts = runs[0]!.map((m) => (m.payload as { text?: string }).text ?? "");
expect(texts).toHaveLength(2);
expect(texts[0]).toBe(block);
expect(parseHandoffMessage(texts[0]!)?.agentId).toBe("alpha");
expect(texts[1]).toBe(`[attached file: ${path.join(dir, "notes.txt")}]`);
});
it("more than the per-request file count is a 413 and writes nothing", async () => {
const res = await api.post(`/api/sessions/${SID}/tasks`, {
input: Array.from({ length: MAX_ATTACHMENT_COUNT + 1 }, (_, i) => ({
type: "file",
fileName: `f${i}.txt`,
dataUrl: dataUrl("x"),
})),
});
expect(res.status).toBe(413);
expect(((await res.json()) as { error: { code: string } }).error.code).toBe("too_many_files");
await expect(fs.access(dir)).rejects.toThrow();
expect(runs).toHaveLength(0);
});
it("more than the per-request total size is a 413 and writes nothing", async () => {
// Two files, each individually legal, that together cross the aggregate cap: the per-file
// check alone would let this through and land both on disk.
const half = Buffer.alloc(Math.floor(MAX_TOTAL_ATTACHMENT_BYTES / 2) + 1).toString("base64");
const res = await api.post(`/api/sessions/${SID}/tasks`, {
input: [
{
type: "file",
fileName: "a.bin",
dataUrl: `data:application/octet-stream;base64,${half}`,
},
{
type: "file",
fileName: "b.bin",
dataUrl: `data:application/octet-stream;base64,${half}`,
},
],
});
expect(res.status).toBe(413);
expect(((await res.json()) as { error: { code: string } }).error.code).toBe(
"payload_too_large",
);
await expect(fs.access(dir)).rejects.toThrow();
expect(runs).toHaveLength(0);
});
it("a busy session without queueIfBusy 409s before the upload is written", async () => {
// Without the pre-check the files land first and the 409 comes after, so the user's retry
// (the Web keeps the chips on failure) would deposit a second copy of every one of them.
let release = () => {};
const parked = new Promise<void>((resolve) => {
release = resolve;
});
t.deps.manager.adopt(row, parkingFakeSession(SID, parked));
await api.post(`/api/sessions/${SID}/tasks`, { input: [{ type: "text", text: "busy" }] });
await waitFor(() => t.deps.manager.statusOf(SID) === "running");
const res = await api.post(`/api/sessions/${SID}/tasks`, {
input: [{ type: "file", fileName: "late.txt", dataUrl: dataUrl("bytes") }],
});
expect(res.status).toBe(409);
expect(((await res.json()) as { error: { code: string } }).error.code).toBe("task_in_progress");
await expect(fs.access(dir)).rejects.toThrow();
release();
await waitFor(() => t.deps.manager.statusOf(SID) === "idle");
});
});
describe("task attachments are removed when the Task never starts", () => {
const FAIL_SID = "session-2026-07-29-11-00-00-aabb0002";
const PID = "failer-default_project";
let t: TestApp;
let api: ReturnType<typeof apiClient>;
let dir: string;
beforeEach(async () => {
// Never adopted into the active table, so startTask goes through the loader — which throws
// here. That is the window the route's cleanup exists for: the pre-check cannot see a
// session it hasn't loaded, so the files are already on disk when the failure happens.
t = await createTestApp({
loader: {
load: async () => {
throw new Error("loader unavailable");
},
},
});
const { cookie } = await provisionUser(t.app, "failer");
api = apiClient(t.app, cookie);
t.deps.sessionsRepo.insert({
sessionId: FAIL_SID,
projectId: PID,
agentId: "default_agent",
provider: "custom",
modelId: "m1",
workspace: "/tmp/w",
approvalMode: "allow-all",
title: null,
createdAt: new Date().toISOString(),
});
dir = path.join(scratchpadDir(t.root, PID, "default_agent"), FAIL_SID);
});
afterEach(async () => {
await t.cleanup();
});
it("a failure after the write leaves no orphaned bytes behind", async () => {
const res = await api.post(`/api/sessions/${FAIL_SID}/tasks`, {
input: [
{ type: "file", fileName: "a.txt", dataUrl: dataUrl("first") },
{ type: "file", fileName: "b.txt", dataUrl: dataUrl("second") },
],
});
expect(res.status).toBe(500);
// The directory may remain (it is the Session's own and is deleted with it); the point is
// that a retry cannot find a stale `a-<hex>.txt` next to its own upload.
expect(await fs.readdir(dir).catch(() => [])).toEqual([]);
});
});
describe("scratchpad directory containment", () => {
const LINK_SID = "session-2026-07-29-12-00-00-aabb0003";
const PID = "linker-default_project";
let t: TestApp;
let api: ReturnType<typeof apiClient>;
let outside: string;
beforeEach(async () => {
t = await createTestApp();
const { cookie } = await provisionUser(t.app, "linker");
api = apiClient(t.app, cookie);
t.deps.sessionsRepo.insert({
sessionId: LINK_SID,
projectId: PID,
agentId: "default_agent",
provider: "custom",
modelId: "m1",
workspace: "/tmp/w",
approvalMode: "allow-all",
title: null,
createdAt: new Date().toISOString(),
});
t.deps.manager.adopt(
t.deps.sessionsRepo.findById(LINK_SID)!,
recordingFakeSession(LINK_SID, []),
);
outside = await fs.mkdtemp(path.join(os.tmpdir(), "penguin-outside-"));
});
afterEach(async () => {
await fs.rm(outside, { recursive: true, force: true });
await t.cleanup();
});
// Symlink creation needs a privilege or developer mode on Windows; the containment rule
// itself is platform-independent.
it.skipIf(process.platform === "win32")(
"a session directory symlinked out of the scratchpad root is refused, not written through",
async () => {
const root = scratchpadDir(t.root, PID, "default_agent");
await fs.mkdir(root, { recursive: true });
// `fs.mkdir(dir, {recursive:true})` succeeds silently on an existing symlink-to-directory,
// so without the realpath check the upload would land in `outside`.
await fs.symlink(outside, path.join(root, LINK_SID), "dir");
const res = await api.post(`/api/sessions/${LINK_SID}/tasks`, {
input: [{ type: "file", fileName: "escape.txt", dataUrl: dataUrl("bytes") }],
});
expect(res.status).toBe(500);
expect(await fs.readdir(outside)).toEqual([]);
},
);
});