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
+12 -2
View File
@@ -604,11 +604,21 @@ export interface MessagesResponse {
// ---------------------------------------------------------------------------
/**
* A single Prompt's input parts: text or image (data: / http(s) URL).
* A single Prompt's input parts: text, image (data: / http(s) URL), or an uploaded file.
* Docs: /docs/server-api § "Session-Level Endpoints".
*/
export type TaskInputPart =
{ type: "text"; text: string } | { type: "image_url"; imageUrl: string };
| { type: "text"; text: string }
| { type: "image_url"; imageUrl: string }
/**
* File attachment (the composer's "+" menu): `dataUrl` is a base64 `data:` URL of the
* file's bytes, capped at 10MB each (413 `file_too_large` beyond that; the request as a
* whole still has to fit the global 20MB body limit). The server writes it into the
* Session scratchpad under a sanitized name and appends an `[attached file: <path>]` line
* to the message text — the bytes never enter the conversation, the model opens the file
* by path. `fileName` is the original name (no path separators, no `..`).
*/
| { type: "file"; fileName: string; dataUrl: string };
export interface TaskCreateRequest {
input: TaskInputPart[];
+18 -7
View File
@@ -11,6 +11,7 @@ import fsp from "node:fs/promises";
import path from "node:path";
import { Hono } from "hono";
import type { Context } from "hono";
import { bodyLimit } from "hono/body-limit";
import type { DatabaseSync } from "node:sqlite";
import type { ServerConfig } from "./config.js";
import { openDatabase } from "./db/database.js";
@@ -332,13 +333,23 @@ export function createApp(deps: AppDeps): Hono<AppEnv> {
}
// API common defenses: request body size cap (20MB) and write-request Content-Type (one of the CSRF MVP defenses).
app.use("/api/*", async (c, next) => {
const contentLength = Number(c.req.header("content-length") ?? 0);
if (contentLength > MAX_BODY_BYTES) {
throw new HttpError(413, "payload_too_large", "Request body exceeds the 20MB limit.");
}
await next();
});
//
// The cap has to be measured, not read: a chunked request carries no `content-length` at all,
// so a header check alone passes a body of any size — the sinks behind it (task input images,
// file attachments, Trace import) then decode whatever arrives. hono's bodyLimit keeps the
// header fast path when the length is declared and otherwise counts bytes off the stream,
// aborting the moment the total crosses the cap.
app.use(
"/api/*",
bodyLimit({
maxSize: MAX_BODY_BYTES,
// Its default is a bare text/plain 413; throw the App's own error instead so the response
// stays the documented `payload_too_large` body that every client already handles.
onError: () => {
throw new HttpError(413, "payload_too_large", "Request body exceeds the 20MB limit.");
},
}),
);
app.use("/api/*", jsonOnlyWrites);
// Public routes (no login required).
+134 -32
View File
@@ -46,6 +46,13 @@ import {
} from "../validate.js";
import type { AppDeps } from "../../app.js";
import { MAX_UPLOAD_BYTES } from "../../services/workspace-files-service.js";
import {
assertAttachmentBudget,
attachFilesToInput,
parseAttachmentPart,
removeAttachments,
} from "../../services/task-attachments.js";
import type { TaskAttachment } from "../../services/task-attachments.js";
/** Max title length for manual renames: looser than the auto-generated 30-char limit, to accommodate users' own organizing conventions. */
const SESSION_TITLE_MAX = 120;
@@ -72,13 +79,48 @@ const SESSION_CATEGORIES: readonly SessionCategory[] = [
"archived",
];
/** Validate Prompt input parts: text or image (data: / http(s) URL). */
function parseTaskInput(body: Record<string, unknown>): OmniMessage[] {
/**
* Resolve a scratchpad file name to an absolute path inside `dir`, or null when it could point
* anywhere else (the caller turns that into the same 404 a missing file gets, so a probe learns
* nothing either way).
*
* A character whitelist is deliberately NOT the guard: an attachment keeps the name the user
* gave it, `报告.pdf` included, so the check is structural instead — no separators, no control
* characters, not a relative marker — and then *confirmed* by resolving the path and requiring
* its parent to be this session's directory exactly. That last step is what actually contains
* the read: it also rejects the shapes a character class misses, such as a Windows
* drive-relative `C:evil.png`.
*/
function resolveScratchpadFile(dir: string, fileName: string): string | null {
if (!fileName || fileName === "." || fileName === "..") return null;
for (const ch of fileName) {
const code = ch.codePointAt(0)!;
if (code < 0x20 || code === 0x7f || ch === "/" || ch === "\\") return null;
}
const resolved = path.resolve(dir, fileName);
return path.dirname(resolved) === path.resolve(dir) ? resolved : null;
}
/**
* A validated Prompt: the message parts that go straight into the run, plus the file
* attachments, which still have to be written to disk (see attachFilesToInput). Kept apart
* because validation stays synchronous and side-effect free — nothing touches the filesystem
* until the request is known to be good, and goal mode can reject files before any bytes land.
*/
interface ParsedTaskInput {
messages: OmniMessage[];
attachments: TaskAttachment[];
}
/** Validate Prompt input parts: text, image (data: / http(s) URL), or an uploaded file. */
function parseTaskInput(body: Record<string, unknown>): ParsedTaskInput {
const input = body.input;
if (!Array.isArray(input) || input.length === 0) {
throw badRequest("input must be an array with at least one item.");
}
return input.map((item, i) => {
const messages: OmniMessage[] = [];
const attachments: TaskAttachment[] = [];
input.forEach((item, i) => {
if (item === null || typeof item !== "object" || Array.isArray(item)) {
throw badRequest(`input[${i}] must be an object.`);
}
@@ -87,7 +129,8 @@ function parseTaskInput(body: Record<string, unknown>): OmniMessage[] {
if (typeof part.text !== "string" || part.text.length === 0) {
throw badRequest(`input[${i}].text must be a non-empty string.`);
}
return userText(part.text);
messages.push(userText(part.text));
return;
}
if (part.type === "image_url") {
const url = part.imageUrl;
@@ -97,10 +140,21 @@ function parseTaskInput(body: Record<string, unknown>): OmniMessage[] {
) {
throw badRequest(`input[${i}].imageUrl only supports data: or http(s) URLs.`);
}
return imageUrlMessage(url);
messages.push(imageUrlMessage(url));
return;
}
throw badRequest(`input[${i}].type must be one of text / image_url.`);
if (part.type === "file") {
// Not an OmniMessage of its own: the file becomes an `[attached file: …]` line on the
// text message once written to the scratchpad, so it carries no payload into the run.
attachments.push(parseAttachmentPart(part, i));
// Per-request count / total-bytes caps, re-checked on every part so a hostile `input`
// is cut off at the item that crosses the line (see assertAttachmentBudget).
assertAttachmentBudget(attachments);
return;
}
throw badRequest(`input[${i}].type must be one of text / image_url / file.`);
});
return { messages, attachments };
}
/**
@@ -323,29 +377,33 @@ export function sessionsRoutes(deps: AppDeps): Hono<AppEnv> {
return c.body(null, 204);
});
// Session scratchpad files (e.g. input images saved to disk for image-unsupported
// models): read by filename, so the conversation UI can render a message's
// "[attached image: <path>]" attachment line back into an image. Restricted to this
// session's own scratchpad directory (the filename must not contain a path
// separator, blocking traversal); filenames include a timestamp and are globally
// unique, so the response is marked immutable and long-cacheable.
// Session scratchpad files (input images saved to disk for image-unsupported models, the
// composer's file attachments, model-generated temp files): read by filename, so the
// conversation UI can render a message's "[attached image: <path>]" attachment line back
// into an image. Restricted to this session's own scratchpad directory (see
// resolveScratchpadFile); a name is never reused for different bytes — uploads take a random
// suffix on collision — so the response is marked immutable and long-cacheable.
app.get("/:sessionId/scratchpad/:fileName", async (c) => {
const row = resolveSession(c);
const fileName = c.req.param("fileName") ?? "";
if (!/^[A-Za-z0-9._-]+$/.test(fileName) || fileName.includes("..")) {
throw new HttpError(404, "file_not_found", "File does not exist.");
}
const filePath = path.join(
scratchpadDir(deps.config.root, row.projectId, row.agentId),
row.sessionId,
const filePath = resolveScratchpadFile(
path.join(scratchpadDir(deps.config.root, row.projectId, row.agentId), row.sessionId),
fileName,
);
if (!filePath) throw new HttpError(404, "file_not_found", "File does not exist.");
let bytes: Buffer;
try {
bytes = await fs.readFile(filePath);
} catch {
throw new HttpError(404, "file_not_found", "File does not exist.");
}
// SECURITY BOUNDARY — do not extend casually. This map is an allowlist of types that are
// safe to hand a browser inline from the App's own origin, and it is the only reason the
// bytes below (arbitrary user uploads and Agent-written temp files) cannot become stored
// XSS. Every image type here is inert when rendered. Adding `.svg`, `.html`, `.pdf` or
// anything else that a browser parses as a document would look like a one-line convenience
// and would immediately be same-origin script execution — such a type needs the treatment
// the Workspace read gives it (plain-text downgrade or a sandbox CSP), not a map entry.
const MIME_BY_EXT: Record<string, string> = {
".png": "image/png",
".jpg": "image/jpeg",
@@ -353,9 +411,23 @@ export function sessionsRoutes(deps: AppDeps): Hono<AppEnv> {
".gif": "image/gif",
".webp": "image/webp",
};
const mime = MIME_BY_EXT[path.extname(fileName).toLowerCase()] ?? "application/octet-stream";
const mime = MIME_BY_EXT[path.extname(fileName).toLowerCase()];
return c.body(new Uint8Array(bytes), 200, {
"content-type": mime,
"content-type": mime ?? "application/octet-stream",
// nosniff: the composer's file attachments land in this same directory, so the bytes
// here are arbitrary user content served from the App's own origin — without it a
// browser could sniff an `application/octet-stream` upload back into HTML and run it
// same-origin (the same defense workspace file reads apply).
"x-content-type-options": "nosniff",
// Second, independent layer for everything that fell off the allowlist: the only reason
// this endpoint is fetched inline is the conversation's <img> tags, so anything that is
// not one of those images is served as a download and never renders as a document —
// nosniff alone would be the whole defense otherwise.
...(mime === undefined
? {
"content-disposition": `attachment; filename*=UTF-8''${encodeURIComponent(fileName)}`,
}
: {}),
"cache-control": "private, max-age=31536000, immutable",
});
});
@@ -420,33 +492,63 @@ export function sessionsRoutes(deps: AppDeps): Hono<AppEnv> {
const thinkingLevel = optionalEnum(body, "thinkingLevel", THINKING_LEVELS);
if (goal) {
// Goal mode: the input must be plain non-empty text (its marker-stripped text becomes
// the objective, re-injected every round — images have no place in the protocol).
const input = parseTaskInput(body);
const text = input
// the objective, re-injected every round — images and file attachments have no place in
// the protocol; rejected before any upload is written to disk).
const { messages, attachments } = parseTaskInput(body);
const text = messages
.filter((m) => (m.payload as { type?: string }).type === "text")
.map((m) => (m.payload as { text: string }).text)
.join("\n")
.trim();
if (!text || input.some((m) => (m.payload as { type?: string }).type !== "text")) {
if (
!text ||
attachments.length > 0 ||
messages.some((m) => (m.payload as { type?: string }).type !== "text")
) {
throw badRequest("goal mode requires text-only input (the objective).");
}
const { sessionId } = await deps.manager.startGoal(row.sessionId, {
input,
input: messages,
budget: goal.budget,
...(thinkingLevel !== undefined ? { thinkingLevel } : {}),
});
return c.json({ sessionId } satisfies TaskCreateResponse, 202);
}
const input = parseTaskInput(body);
const parsed = parseTaskInput(body);
// Follow-up queue: with queueIfBusy, a busy session enqueues the input instead of 409
// (auto-starts as an ordinary next task once idle; the response says which happened).
const queueIfBusy = body.queueIfBusy === true;
// 202: the Task executes on the server, decoupled from the SSE connection; sessionId is the current actual id (the new id after self-heal).
const { sessionId, queued } = await deps.manager.startTask(row.sessionId, input, {
...(thinkingLevel !== undefined ? { thinkingLevel } : {}),
queueIfBusy,
});
return c.json({ sessionId, queued } satisfies TaskCreateResponse, 202);
// Advisory pre-check, so the overwhelmingly common rejection — sending while a Task is
// running, without queueIfBusy — never writes bytes it would then have to take back. The
// authoritative check still runs under the Session lock inside startTask; this one is
// lock-free and may pass on a race, which the cleanup below covers.
deps.manager.assertCanAcceptTask(row.sessionId, { queueIfBusy });
// File attachments land in this Session's scratchpad (deleted along with the Session) and
// are handed to the model as `[attached file: <path>]` lines on the message text. Written
// even when the task ends up queued as a follow-up: the queued input must be complete, and
// the queue is drained by this same Session. A Trace-less Session that self-heals into a
// new id below keeps its files under the id they were written with — the paths in the
// message stay valid; only the delete-with-the-Session cleanup misses them in that case.
const { input, written } = await attachFilesToInput(
parsed.messages,
parsed.attachments,
scratchpadDir(deps.config.root, row.projectId, row.agentId),
row.sessionId,
);
try {
// 202: the Task executes on the server, decoupled from the SSE connection; sessionId is the current actual id (the new id after self-heal).
const { sessionId, queued } = await deps.manager.startTask(row.sessionId, input, {
...(thinkingLevel !== undefined ? { thinkingLevel } : {}),
queueIfBusy,
});
return c.json({ sessionId, queued } satisfies TaskCreateResponse, 202);
} catch (err) {
// The Task never started, so nothing references these files and nothing will ever clean
// them up — and the Web keeps the chips on failure, so the user's retry would otherwise
// land a second copy of every one of them.
await removeAttachments(written);
throw err;
}
});
// Mid-run steering: queue a user message for the running Task; core delivers it between
@@ -442,6 +442,24 @@ export class SessionManager {
// —— Task / compaction drive ——
/**
* Cheap, lock-free rehearsal of the 409/503 conditions startTask checks, throwing exactly the
* same HttpErrors. **Advisory only**: it neither takes the Session lock nor loads an entry, so
* a session that isn't in the active table reads as acceptable and a status change racing this
* call is not caught — the authoritative check is still the one inside startTask.
*
* It exists so a caller that has irreversible work to do first (POST /tasks writes the
* message's file attachments to disk) can find out about the ordinary "a Task is already
* running" rejection before doing it, instead of undoing it afterwards.
*/
assertCanAcceptTask(sessionId: string, opts?: { queueIfBusy?: boolean }): void {
this.assertOpen();
this.assertAgentNotDeleting(sessionId);
this.assertSessionNotDeleting(sessionId);
const entry = this.entries.get(sessionId);
if (entry && !opts?.queueIfBusy) this.assertIdle(entry);
}
/**
* Start a Task: get-or-load → 409
* mutual-exclusion check → publish the input messages first → drive run in the
@@ -0,0 +1,297 @@
/**
* Composer file attachments — the `{type:"file"}` variant of TaskInputPart.
*
* The browser has no Session while a chat is still a draft, so there is no upload endpoint to
* call before sending: the file rides the task request itself as a base64 `data:` URL, exactly
* like a pasted image does. On the way in it is written to the **Session scratchpad**
* (`<agent>/scratchpad/<sessionId>/`, deleted with the Session, so cleanup is free) and the
* message text gains one `[attached file: <absolute path>]` line per file — the bytes never
* enter the conversation, the model opens the file by path with its ordinary file tools.
*
* The line format and its placement are not defined here: they are shared with core's
* `[attached image: …]` producer and the Web renderer that parses both
* (`@prismshadow/penguin-core/markers` → attachment-lines.ts, plus `appendAttachmentLines`),
* so the two conventions cannot drift apart.
*/
import fs from "node:fs/promises";
import path from "node:path";
import { randomBytes } from "node:crypto";
import { appendAttachmentLines, attachedFileLine } from "@prismshadow/penguin-core";
import type { OmniMessage } from "@prismshadow/penguin-core";
import { HttpError } from "../http/errors.js";
import { badRequest } from "../http/validate.js";
/**
* Per-file cap. Deliberately below the workspace upload's 14MB: several attachments can ride
* one task request, and the whole body still has to fit the global 20MB limit (base64 inflates
* by 4/3), so a smaller per-file ceiling keeps "one big file" working while leaving room for
* "a handful of ordinary ones". Oversize is a 413, matching the workspace upload route.
*/
export const MAX_ATTACHMENT_BYTES = 10 * 1024 * 1024;
/**
* Per-request caps, checked while the parts are validated — before a single byte reaches the
* disk. They are deliberately NOT delegated to the global body cap: this module has to hold on
* its own, so that a change to that middleware (or a caller that never goes through it) cannot
* silently unbound it. Without them a legal 20MB body fits ~350k minimal `file` parts, which is
* 350k sequential writes into one directory and 350k marker lines on one message.
*
* 20 files is far past any plausible composer use — the chip row stops being usable long before
* that — while still allowing "drop a folder of small files in". 12MB of decoded bytes keeps one
* full-size 10MB attachment usable next to a couple of ordinary ones; base64 inflates by 4/3, so
* 12MB decoded is ~16MB of body and this cap, not the 20MB body cap, is the one a caller
* actually reaches.
*/
export const MAX_ATTACHMENT_COUNT = 20;
export const MAX_TOTAL_ATTACHMENT_BYTES = 12 * 1024 * 1024;
/**
* Longest stem kept on disk, measured in **UTF-8 bytes**: filesystems cap a name near 255
* bytes, and a CJK character costs three of them — a character count would let a Chinese name
* blow the real limit while an English one stayed far under it.
*/
const MAX_STEM_BYTES = 80;
/** Windows reserves these device names with or without an extension (`con`, `con.txt`), case-insensitively. */
const WINDOWS_RESERVED = /^(con|prn|aux|nul|com[1-9]|lpt[1-9])$/i;
/** Format and control characters (Unicode category C) — invisible, and the vector behind right-to-left file-name spoofing. */
const INVISIBLE_CHAR = /\p{C}/u;
/**
* True when a character must not reach a file name. ASCII keeps the long-standing whitelist:
* a space or a shell metacharacter inside a path the model is about to paste into a command is
* a footgun, so anything outside `[A-Za-z0-9._-]` still becomes `-` down there. Above ASCII the
* rule inverts — the character is kept as typed, so `报告.pdf` reaches the model as `报告.pdf`
* instead of collapsing to an anonymous `file.pdf` (CJK, accents and emoji are all harmless to
* a shell). The exception is Unicode category C: invisible controls, and the bidi overrides
* that let a name render as something it is not.
*/
function unsafeNameChar(ch: string): boolean {
if (/[A-Za-z0-9._-]/.test(ch)) return false;
return ch.codePointAt(0)! < 0x80 || INVISIBLE_CHAR.test(ch);
}
/** Replace every unsafe character with `-`, iterating code points so a surrogate pair survives intact. */
function sanitizeSegment(value: string): string {
return Array.from(value, (ch) => (unsafeNameChar(ch) ? "-" : ch)).join("");
}
/** Truncate to a UTF-8 byte budget on character boundaries (iterating a string yields whole code points, so a surrogate pair is never split). */
function truncateBytes(value: string, maxBytes: number): string {
if (Buffer.byteLength(value) <= maxBytes) return value;
let out = "";
for (const ch of value) {
if (Buffer.byteLength(out) + Buffer.byteLength(ch) > maxBytes) break;
out += ch;
}
return out;
}
/** The 6-hex space makes a second collision negligible; the cap only guards against a filesystem stuck on EEXIST. */
const MAX_NAME_ATTEMPTS = 16;
/** One validated attachment, bytes already decoded (they are held in memory only until the write below). */
export interface TaskAttachment {
/** Original file name as submitted (validated: non-empty, no path separators, no `..`). */
fileName: string;
bytes: Buffer;
}
/**
* Validate one `{type:"file"}` input part. Shape problems are 400s in the same style as the
* neighbouring text/image checks; only the size cap answers 413 (`file_too_large`, the code
* the Web App already has copy for). `index` is the part's position in `input`, so the message
* points at the offending item like the other input errors do.
*/
export function parseAttachmentPart(part: Record<string, unknown>, index: number): TaskAttachment {
const fileName = part.fileName;
// Path separators and `..` are rejected rather than sanitized away: the name is the user's,
// and a name that looks like a path means the caller is confused about the contract (the
// write below composes the path itself, and sanitization happens there).
if (
typeof fileName !== "string" ||
fileName.length === 0 ||
fileName.includes("/") ||
fileName.includes("\\") ||
fileName.includes("..") ||
fileName.includes("\0")
) {
throw badRequest(
`input[${index}].fileName must be a non-empty file name without path separators or "..".`,
);
}
const dataUrl = part.dataUrl;
// `[^,]*` for the media type, not `[^;,]*`: a browser may hand out parameters
// (`data:text/plain;charset=utf-8;base64,…`), and only the `;base64,` marker separates the
// type from the payload. The payload's character class is the actual check that it IS
// base64 (whitespace tolerated — line-wrapped encoders decode fine).
const match =
typeof dataUrl === "string" ? /^data:[^,]*;base64,([A-Za-z0-9+/=\s]+)$/.exec(dataUrl) : null;
if (!match) {
throw badRequest(`input[${index}].dataUrl must be a base64 data: URL of the file's bytes.`);
}
const bytes = Buffer.from(match[1]!, "base64");
if (bytes.length === 0) {
throw badRequest(`input[${index}].dataUrl decodes to an empty file.`);
}
if (bytes.length > MAX_ATTACHMENT_BYTES) {
throw new HttpError(
413,
"file_too_large",
`Attached file exceeds the ${MAX_ATTACHMENT_BYTES / (1024 * 1024)}MB limit.`,
);
}
return { fileName, bytes };
}
/**
* Enforce the per-request caps against everything accepted so far. Called after **each** `file`
* part rather than once at the end, so an oversized `input` stops at the part that crosses the
* line instead of base64-decoding the whole array first. Both answer 413: the count reuses a
* dedicated `too_many_files` code, the aggregate the `payload_too_large` the body cap already
* uses — from the caller's side it is the same "this request is too big" outcome.
*/
export function assertAttachmentBudget(attachments: TaskAttachment[]): void {
if (attachments.length > MAX_ATTACHMENT_COUNT) {
throw new HttpError(
413,
"too_many_files",
`A message may carry at most ${MAX_ATTACHMENT_COUNT} attached files.`,
);
}
let total = 0;
for (const a of attachments) total += a.bytes.length;
if (total > MAX_TOTAL_ATTACHMENT_BYTES) {
throw new HttpError(
413,
"payload_too_large",
`Attached files exceed the ${MAX_TOTAL_ATTACHMENT_BYTES / (1024 * 1024)}MB total limit for one message.`,
);
}
}
/**
* Map a submitted name onto a name that is safe on disk **and** still recognizably the user's
* own: `报告 2026.pdf` becomes `报告-2026.pdf` (see unsafeNameChar — the words survive, only the
* shell-hostile ASCII is replaced), so the model reads a meaningful path and a person looking
* at the message recognizes what they attached.
*
* The rest is Windows-shaped hygiene: trailing dots and spaces are dropped (Windows silently
* strips them, so `a.` and `a` would be the same file), a reserved device name is prefixed
* (`con.txt` → `_con.txt`), the stem is capped by UTF-8 bytes, and a stem that sanitizes away
* entirely falls back to `file` rather than producing a bare extension.
*/
function scratchpadName(fileName: string): string {
const ext = sanitizeSegment(path.extname(fileName));
const rawStem = fileName.slice(0, fileName.length - path.extname(fileName).length);
// Trim after truncating: a cut can expose a trailing dot or space that was mid-name before.
const stem = truncateBytes(sanitizeSegment(rawStem), MAX_STEM_BYTES).replace(/[. ]+$/, "");
// Nothing but replacement dashes carries no more information than an empty stem did.
if (!stem || /^-+$/.test(stem)) return `file${ext}`;
return WINDOWS_RESERVED.test(stem) ? `_${stem}${ext}` : `${stem}${ext}`;
}
/**
* Write one attachment into `dir` and return its absolute path. The plain sanitized name is
* tried first (the model — and the user reading the message — sees `report.pdf`, not an opaque
* id); "wx" makes the create exclusive, so a second upload of the same name lands next to the
* first as `report-3f9a1c.pdf` instead of overwriting it (same convention as core's image
* uploads).
*
* "wx" is O_CREAT|O_EXCL, which also refuses to follow a symlink at the final component: a link
* planted at `report.pdf` fails with EEXIST and the retry allocates a suffixed name instead of
* writing through it. The containment check for the *directory* is separate — see openScratchpadDir.
*/
async function writeAttachment(dir: string, attachment: TaskAttachment): Promise<string> {
const base = scratchpadName(attachment.fileName);
const ext = path.extname(base);
const stem = base.slice(0, base.length - ext.length);
for (let attempt = 0; attempt < MAX_NAME_ATTEMPTS; attempt++) {
const name = attempt === 0 ? base : `${stem}-${randomBytes(3).toString("hex")}${ext}`;
const file = path.join(dir, name);
try {
await fs.writeFile(file, attachment.bytes, { flag: "wx" });
return file;
} catch (err) {
if ((err as NodeJS.ErrnoException).code !== "EEXIST") throw err;
}
}
throw new Error(
`failed to allocate a unique attachment file name under ${dir} after ${MAX_NAME_ATTEMPTS} attempts`,
);
}
/**
* Create (or reuse) this Session's scratchpad directory and hand back the path to write into.
*
* `fs.mkdir(…, {recursive:true})` succeeds silently when the directory is already a **symlink**
* to somewhere else, and nothing downstream would notice — so the result is realpath'd and
* required to still sit inside the Agent's scratchpad root, the same containment rule the
* Workspace upload path applies (workspace-files-service.resolveWriteParent). Only the Agent
* process can plant such a link and it runs as this server's uid, so this is consistency rather
* than a privilege boundary; it costs one resolution per message that carries attachments.
*
* The check is on the canonical path but the write stays on the logical one: the path travels
* into the message text, and the read/delete endpoints address a Session by its logical
* directory, so canonicalizing here would only make those disagree on hosts where the data root
* itself sits behind a link (macOS `/var`, a Windows 8.3 temp path).
*/
async function openScratchpadDir(root: string, sessionId: string): Promise<string> {
const dir = path.join(root, sessionId);
await fs.mkdir(dir, { recursive: true });
const canonicalRoot = await fs.realpath(root);
const rel = path.relative(canonicalRoot, await fs.realpath(dir));
if (rel !== sessionId) {
throw new Error(
`session scratchpad ${dir} resolves outside the agent scratchpad root; refusing to write attachments`,
);
}
return dir;
}
/** Best-effort undo of a batch of writes; errors are swallowed because every caller is already on an error path (a failed cleanup must not replace the original failure). */
export async function removeAttachments(files: string[]): Promise<void> {
await Promise.all(files.map((f) => fs.rm(f, { force: true }).catch(() => {})));
}
/** Result of a write batch: the Prompt to run, plus the paths written so the caller can undo them if the Task never starts. */
export interface AttachedFiles {
input: OmniMessage[];
written: string[];
}
/**
* Land every attachment in the Session scratchpad under `root` and return the Prompt with one
* `[attached file: <path>]` line appended per file. Placement follows core's shared rule
* (after the last user text message; attachments-only input becomes a line-only text
* message), so a Prompt carrying both images and files still ends in a single trailing block.
* Returns `messages` untouched when there is nothing to attach — no directory is created.
*
* All-or-nothing: a failure part-way through the batch removes what it already wrote, so a 500
* never leaves files on disk that no message refers to. The caller owns the other half of that
* guarantee — if starting the Task fails afterwards it must call removeAttachments(written),
* otherwise the user's retry would land a second copy of every file.
*/
export async function attachFilesToInput(
messages: OmniMessage[],
attachments: TaskAttachment[],
root: string,
sessionId: string,
): Promise<AttachedFiles> {
if (attachments.length === 0) return { input: messages, written: [] };
const dir = await openScratchpadDir(root, sessionId);
const written: string[] = [];
try {
// Sequential on purpose: the exclusive-create retry above resolves collisions against files
// that already exist, and writing the batch one at a time keeps two same-named uploads in
// the same message from racing each other for the plain name.
for (const attachment of attachments) {
written.push(await writeAttachment(dir, attachment));
}
} catch (err) {
await removeAttachments(written);
throw err;
}
return { input: appendAttachmentLines(messages, written.map(attachedFileLine)), written };
}
+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([]);
},
);
});