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
+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