authorgravatar for me@t3.ggTheo Browne <me@t3.gg> 2026-08-28 02:05:05-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-08-28 02:05:05-07:00
log8f49132214a40c85cf46bf5e3d8ea11c04a9610a
treefbacb523a1ef1f7b9de13ffb96b2fa2057f1e4b3
parent88be5631ff6a48b98ead48fa5af6a2be0a4d63e4
signature Signed by PGP key B5690EEEBB952194

feat(server): accept PDF, ZIP, and other file uploads up to 50MB (#8235)

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>

38 files changed, 1042 insertions(+), 89 deletions(-)

apps/mobile/src/features/threads/ThreadFeed.tsx+3-1
......@@ -1040,7 +1040,9 @@ function renderFeedEntry(
10401040 const isUser = message.role === "user";
10411041 const styles = isUser ? markdownStyles.user : markdownStyles.assistant;
10421042 const timestampLabel = formatMessageTime(isUser ? message.createdAt : message.updatedAt);
1043 const attachments = message.attachments ?? [];
1043 const attachments = (message.attachments ?? []).filter(
1044 (attachment) => attachment.type === "image",
1045 );
10441046 const hasReviewCommentContext = message.text.includes("<review_comment");
10451047 // A bubble that sizes itself from its content cannot lay out a block whose
10461048 // intrinsic width overflows `maxWidth`: Android positions the bubble's
apps/server/src/assets/AssetAccess.ts+30-4
......@@ -37,7 +37,7 @@ import {
3737 timingSafeEqualBase64Url,
3838} from "../auth/utils.ts";
3939import * as ServerSecretStore from "../auth/ServerSecretStore.ts";
40import { resolveAttachmentPathById } from "../attachmentStore.ts";
40import { parseAttachmentFileExtension, resolveAttachmentPathById } from "../attachmentStore.ts";
4141import * as ServerConfig from "../config.ts";
4242import * as ProjectFaviconResolver from "../project/ProjectFaviconResolver.ts";
4343import * as WorkspacePaths from "../workspace/WorkspacePaths.ts";
......@@ -79,6 +79,13 @@ const AssetClaimsSchema = Schema.Union([
7979 version: Schema.Literal(1),
8080 kind: Schema.Literal("attachment"),
8181 attachmentId: Schema.String,
82 /** Decided at mint time. Absent tokens (from before this field) serve
83 inline, which is only ever the image case. */
84 download: Schema.optionalKey(Schema.Boolean),
85 /** Display name and mime the caller supplied at mint time; drive the
86 download filename and Content-Type. */
87 fileName: Schema.optionalKey(Schema.String),
88 mimeType: Schema.optionalKey(Schema.String),
8289 expiresAt: Schema.Number,
8390 }),
8491 Schema.Struct({
......@@ -101,7 +108,13 @@ const AssetClaimsJson = Schema.fromJsonString(AssetClaimsSchema);
101108const decodeAssetClaims = Schema.decodeUnknownOption(AssetClaimsJson);
102109const encodeAssetClaims = Schema.encodeSync(AssetClaimsJson);
103110
104export type ResolvedAsset = { readonly kind: "file"; readonly path: string };
111export type ResolvedAsset = {
112 readonly kind: "file";
113 readonly path: string;
114 readonly download?: boolean;
115 readonly fileName?: string;
116 readonly mimeType?: string;
117};
105118
106119function decodeClaims(encodedPayload: string): AssetClaims | null {
107120 try {
......@@ -286,13 +299,20 @@ export const issueAssetUrl = Effect.fn("AssetAccess.issueAssetUrl")(function* (i
286299 resource: input.resource,
287300 });
288301 }
302 // Generic files carry their extension inside the attachment id (that
303 // shape resolves the on-disk path); images do not. Only generic files
304 // download, images render inline in chat.
305 const isGenericFile = parseAttachmentFileExtension(input.resource.attachmentId) !== null;
289306 claims = {
290307 version: 1,
291308 kind: "attachment",
292309 attachmentId: input.resource.attachmentId,
310 ...(isGenericFile ? { download: true } : {}),
311 ...(input.resource.fileName !== undefined ? { fileName: input.resource.fileName } : {}),
312 ...(input.resource.mimeType !== undefined ? { mimeType: input.resource.mimeType } : {}),
293313 expiresAt,
294314 };
295 fileName = path.basename(attachmentPath);
315 fileName = input.resource.fileName ?? path.basename(attachmentPath);
296316 break;
297317 }
298318 case "project-favicon": {
......@@ -464,7 +484,13 @@ export const resolveAsset = Effect.fn("AssetAccess.resolveAsset")(function* (
464484 Effect.orElseSucceed(() => Option.none()),
465485 );
466486 return Option.isSome(info) && info.value.type === "File"
467 ? ({ kind: "file", path: attachmentPath } satisfies ResolvedAsset)
487 ? ({
488 kind: "file",
489 path: attachmentPath,
490 ...(claims.download ? { download: true } : {}),
491 ...(claims.fileName !== undefined ? { fileName: claims.fileName } : {}),
492 ...(claims.mimeType !== undefined ? { mimeType: claims.mimeType } : {}),
493 } satisfies ResolvedAsset)
468494 : null;
469495 }
470496
apps/server/src/assets/AttachmentUpload.test.ts+122
......@@ -4,11 +4,16 @@ import * as NodePath from "node:path";
44
55import * as NodeServices from "@effect/platform-node/NodeServices";
66import { describe, expect, it } from "@effect/vitest";
7import * as Deferred from "effect/Deferred";
78import * as Effect from "effect/Effect";
9import * as Fiber from "effect/Fiber";
810import * as Layer from "effect/Layer";
11import * as Schema from "effect/Schema";
12import * as Stream from "effect/Stream";
913import * as TestClock from "effect/testing/TestClock";
1014
1115import * as ServerSecretStore from "../auth/ServerSecretStore.ts";
16import { base64UrlEncode, signPayload } from "../auth/utils.ts";
1217import * as ServerConfig from "../config.ts";
1318import { parseThreadSegmentFromAttachmentId } from "../attachmentStore.ts";
1419import {
......@@ -30,6 +35,19 @@ const uploadInput = {
3035 sizeBytes: 6,
3136} as const;
3237
38const LegacyAttachmentUploadClaims = Schema.Struct({
39 version: Schema.Literal(1),
40 kind: Schema.Literal("attachment-upload"),
41 attachmentId: Schema.String,
42 name: Schema.String,
43 mimeType: Schema.String,
44 sizeBytes: Schema.Number,
45 expiresAt: Schema.Number,
46});
47const encodeLegacyAttachmentUploadClaims = Schema.encodeEffect(
48 Schema.fromJsonString(LegacyAttachmentUploadClaims),
49);
50
3351describe("AttachmentUpload", () => {
3452 it.effect("signs the attachment metadata and validates the upload token", () =>
3553 Effect.gen(function* () {
......@@ -59,6 +77,31 @@ describe("AttachmentUpload", () => {
5977 }).pipe(Effect.provide(testLayer)),
6078 );
6179
80 it.effect("accepts unexpired image upload tokens issued before file support", () =>
81 Effect.gen(function* () {
82 const issued = yield* issueAttachmentUploadUrl(uploadInput);
83 const secretStore = yield* ServerSecretStore.ServerSecretStore;
84 const secret = yield* secretStore.getOrCreateRandom("asset-access-signing-key", 32);
85 const encodedPayload = base64UrlEncode(
86 yield* encodeLegacyAttachmentUploadClaims({
87 version: 1,
88 kind: "attachment-upload",
89 attachmentId: issued.attachmentId,
90 name: uploadInput.name,
91 mimeType: uploadInput.mimeType,
92 sizeBytes: uploadInput.sizeBytes,
93 expiresAt: issued.expiresAt,
94 }),
95 );
96 const legacyToken = `${encodedPayload}.${signPayload(encodedPayload, secret)}`;
97
98 expect(yield* validateAttachmentUploadToken(legacyToken)).toMatchObject({
99 type: "image",
100 attachmentId: issued.attachmentId,
101 });
102 }).pipe(Effect.provide(testLayer)),
103 );
104
62105 it.effect("rejects expired upload tokens", () =>
63106 Effect.gen(function* () {
64107 const issued = yield* issueAttachmentUploadUrl(uploadInput);
......@@ -108,6 +151,85 @@ describe("AttachmentUpload", () => {
108151 }).pipe(Effect.provide(testLayer)),
109152 );
110153
154 it.effect("streams generic files to a path with their original extension", () =>
155 Effect.gen(function* () {
156 const config = yield* ServerConfig.ServerConfig;
157 const issued = yield* issueAttachmentUploadUrl({
158 type: "file",
159 name: "report.PDF",
160 mimeType: "application/pdf",
161 sizeBytes: 6,
162 });
163 const token = issued.relativeUrl.slice(`${ATTACHMENT_UPLOAD_ROUTE_PREFIX}/`.length);
164 const claims = yield* validateAttachmentUploadToken(token);
165 if (!claims) {
166 throw new Error("Expected valid upload claims.");
167 }
168
169 expect(
170 yield* storeAttachmentUpload(
171 claims,
172 Stream.make(new Uint8Array([1, 2, 3]), new Uint8Array([4, 5, 6])),
173 ),
174 ).toEqual({ ok: true });
175 expect(issued.attachmentId).toMatch(/-pdf$/);
176 expect(
177 NodeFS.readFileSync(NodePath.join(config.attachmentsDir, `${issued.attachmentId}.pdf`)),
178 ).toEqual(Buffer.from([1, 2, 3, 4, 5, 6]));
179
180 yield* deletePendingAttachment(issued.attachmentId);
181 expect(NodeFS.readdirSync(config.attachmentsDir)).toEqual([]);
182 }).pipe(Effect.provide(testLayer)),
183 );
184
185 it.effect("removes partial streamed uploads that exceed their signed size", () =>
186 Effect.gen(function* () {
187 const config = yield* ServerConfig.ServerConfig;
188 const issued = yield* issueAttachmentUploadUrl(uploadInput);
189 const token = issued.relativeUrl.slice(`${ATTACHMENT_UPLOAD_ROUTE_PREFIX}/`.length);
190 const claims = yield* validateAttachmentUploadToken(token);
191 if (!claims) {
192 throw new Error("Expected valid upload claims.");
193 }
194
195 expect(yield* storeAttachmentUpload(claims, Stream.make(new Uint8Array(7)))).toMatchObject({
196 ok: false,
197 status: 400,
198 });
199 expect(NodeFS.readdirSync(config.attachmentsDir)).toEqual([]);
200 }).pipe(Effect.provide(testLayer)),
201 );
202
203 it.effect("removes partial streamed uploads when the upload is interrupted", () =>
204 Effect.gen(function* () {
205 const config = yield* ServerConfig.ServerConfig;
206 const issued = yield* issueAttachmentUploadUrl(uploadInput);
207 const token = issued.relativeUrl.slice(`${ATTACHMENT_UPLOAD_ROUTE_PREFIX}/`.length);
208 const claims = yield* validateAttachmentUploadToken(token);
209 if (!claims) {
210 throw new Error("Expected valid upload claims.");
211 }
212
213 const nextChunkRequested = yield* Deferred.make<void>();
214 const body = Stream.make(new Uint8Array([1, 2, 3])).pipe(
215 Stream.concat(
216 Stream.fromEffect(
217 Deferred.succeed(nextChunkRequested, undefined).pipe(Effect.andThen(Effect.never)),
218 ),
219 ),
220 );
221 const upload = yield* storeAttachmentUpload(claims, body).pipe(Effect.forkScoped);
222
223 yield* Deferred.await(nextChunkRequested);
224 expect(
225 NodeFS.readdirSync(config.attachmentsDir).filter((entry) => entry.endsWith(".part")),
226 ).toHaveLength(1);
227
228 yield* Fiber.interrupt(upload);
229 expect(NodeFS.readdirSync(config.attachmentsDir)).toEqual([]);
230 }).pipe(Effect.provide(testLayer)),
231 );
232
111233 it.effect("deletes pending uploads without deleting thread-owned copies", () =>
112234 Effect.gen(function* () {
113235 const config = yield* ServerConfig.ServerConfig;
apps/server/src/assets/AttachmentUpload.ts+43-14
......@@ -12,8 +12,11 @@ import * as FileSystem from "effect/FileSystem";
1212import * as Option from "effect/Option";
1313import * as Path from "effect/Path";
1414import * as Schema from "effect/Schema";
15import * as Stream from "effect/Stream";
16import type * as HttpServerRequest from "effect/unstable/http/HttpServerRequest";
1517
1618import {
19 attachmentFileExtension,
1720 createPendingAttachmentId,
1821 parseThreadSegmentFromAttachmentId,
1922 PENDING_ATTACHMENT_THREAD_SEGMENT,
......@@ -41,6 +44,9 @@ const lastPendingSweepByDirectory = new Map<string, number>();
4144const AttachmentUploadClaims = Schema.Struct({
4245 version: Schema.Literal(1),
4346 kind: Schema.Literal("attachment-upload"),
47 type: Schema.Literals(["image", "file"]).pipe(
48 Schema.withDecodingDefault(Effect.succeed("image" as const)),
49 ),
4450 attachmentId: Schema.String,
4551 name: Schema.String,
4652 mimeType: Schema.String,
......@@ -89,12 +95,16 @@ export const issueAttachmentUploadUrl = Effect.fn("AttachmentUpload.issueUrl")(f
8995 }
9096 }
9197
92 const attachmentId = createPendingAttachmentId();
98 const attachmentType = input.type ?? "image";
99 const attachmentId = createPendingAttachmentId(
100 attachmentType === "file" ? attachmentFileExtension(input.name) : undefined,
101 );
93102 const expiresAt = nowMs + ATTACHMENT_UPLOAD_URL_TTL_MS;
94103 const encodedPayload = base64UrlEncode(
95104 encodeAttachmentUploadClaims({
96105 version: 1,
97106 kind: "attachment-upload",
107 type: attachmentType,
98108 attachmentId,
99109 name: input.name,
100110 mimeType: input.mimeType,
......@@ -141,18 +151,21 @@ export type StoreAttachmentUploadResult =
141151
142152export const storeAttachmentUpload = Effect.fn("AttachmentUpload.store")(function* (
143153 claims: AttachmentUploadClaims,
144 bytes: Uint8Array,
154 body: Uint8Array | HttpServerRequest.HttpServerRequest["stream"],
145155) {
146 if (bytes.byteLength !== claims.sizeBytes) {
156 if (body instanceof Uint8Array && body.byteLength !== claims.sizeBytes) {
147157 return {
148158 ok: false,
149159 status: 400,
150 detail: `Body was ${bytes.byteLength} bytes, expected ${claims.sizeBytes}.`,
160 detail: `Body was ${body.byteLength} bytes, expected ${claims.sizeBytes}.`,
151161 } satisfies StoreAttachmentUploadResult;
152162 }
153163
154164 const config = yield* ServerConfig.ServerConfig;
155 const extension = inferImageExtension({ mimeType: claims.mimeType, fileName: claims.name });
165 const extension =
166 claims.type === "file"
167 ? attachmentFileExtension(claims.name)
168 : inferImageExtension({ mimeType: claims.mimeType, fileName: claims.name });
156169 const relativePath = `${claims.attachmentId}${extension}`;
157170 const finalPath = resolveAttachmentRelativePath({
158171 attachmentsDir: config.attachmentsDir,
......@@ -168,21 +181,34 @@ export const storeAttachmentUpload = Effect.fn("AttachmentUpload.store")(functio
168181
169182 const fileSystem = yield* FileSystem.FileSystem;
170183 const path = yield* Path.Path;
184 let receivedBytes = 0;
185 const bodyStream = body instanceof Uint8Array ? Stream.make(body) : body;
171186 return yield* Effect.gen(function* () {
172187 yield* fileSystem.makeDirectory(path.dirname(finalPath), { recursive: true });
173 yield* fileSystem.writeFile(partPath, bytes);
188 yield* Stream.run(
189 bodyStream.pipe(
190 Stream.takeWhile((chunk) => {
191 receivedBytes += chunk.byteLength;
192 return receivedBytes <= claims.sizeBytes;
193 }),
194 ),
195 fileSystem.sink(partPath),
196 );
197 if (receivedBytes !== claims.sizeBytes) {
198 return {
199 ok: false,
200 status: 400,
201 detail: `Body was ${receivedBytes} bytes, expected ${claims.sizeBytes}.`,
202 } satisfies StoreAttachmentUploadResult;
203 }
174204 yield* fileSystem.rename(partPath, finalPath);
175205 return { ok: true } satisfies StoreAttachmentUploadResult;
176206 }).pipe(
177207 Effect.catch((cause) =>
178 fileSystem.remove(partPath, { force: true }).pipe(
179 Effect.orElseSucceed(() => undefined),
180 Effect.andThen(
181 Effect.logError("Failed to persist attachment upload.", {
182 attachmentId: claims.attachmentId,
183 cause,
184 }),
185 ),
208 Effect.logError("Failed to persist attachment upload.", {
209 attachmentId: claims.attachmentId,
210 cause,
211 }).pipe(
186212 Effect.as({
187213 ok: false,
188214 status: 500,
......@@ -190,6 +216,9 @@ export const storeAttachmentUpload = Effect.fn("AttachmentUpload.store")(functio
190216 } satisfies StoreAttachmentUploadResult),
191217 ),
192218 ),
219 Effect.ensuring(
220 fileSystem.remove(partPath, { force: true }).pipe(Effect.orElseSucceed(() => undefined)),
221 ),
193222 );
194223});
195224
apps/server/src/attachmentStore.test.ts+36-2
......@@ -6,9 +6,11 @@ import * as NodePath from "node:path";
66import { describe, expect, it } from "vite-plus/test";
77
88import {
9 attachmentFileExtension,
910 createAttachmentId,
1011 createPendingAttachmentId,
1112 parseAttachmentUuid,
13 parseAttachmentFileExtension,
1214 planAttachmentClaim,
1315 parseThreadSegmentFromAttachmentId,
1416 resolveAttachmentPathById,
......@@ -58,6 +60,21 @@ describe("attachmentStore", () => {
5860 );
5961 });
6062
63 it("preserves safe file extensions in attachment ids and paths", () => {
64 const attachmentId = createPendingAttachmentId(".PDF");
65
66 expect(parseThreadSegmentFromAttachmentId(attachmentId)).toBe("pending");
67 expect(parseAttachmentUuid(attachmentId)).toMatch(/^[a-f0-9-]{36}$/);
68 expect(parseAttachmentFileExtension(attachmentId)).toBe("pdf");
69 expect(attachmentFileExtension("report.PDF")).toBe(".pdf");
70 expect(attachmentFileExtension("report")).toBe(".bin");
71 expect(attachmentFileExtension("report.extensiontoolong")).toBe(".bin");
72 // ".part" is the in-flight upload suffix; storing it would make the file
73 // look like a stale partial to the sweep.
74 expect(attachmentFileExtension("archive.part")).toBe(".bin");
75 expect(createAttachmentId("x".repeat(80), ".abcdefghij")?.length).toBeLessThanOrEqual(128);
76 });
77
6178 it("resolves attachment path by id using the extension that exists on disk", () => {
6279 const attachmentsDir = NodeFS.mkdtempSync(
6380 NodePath.join(NodeOS.tmpdir(), "t3code-attachment-store-"),
......@@ -92,6 +109,21 @@ describe("attachmentStore", () => {
92109 }
93110 });
94111
112 it("resolves generic attachments without scanning the attachment directory", () => {
113 const attachmentsDir = NodeFS.mkdtempSync(
114 NodePath.join(NodeOS.tmpdir(), "t3code-file-attachment-"),
115 );
116 try {
117 const attachmentId = "thread-1-00000000-0000-4000-8000-000000000001-zip";
118 const archivePath = NodePath.join(attachmentsDir, `${attachmentId}.zip`);
119 NodeFS.writeFileSync(archivePath, Buffer.from("archive"));
120
121 expect(resolveAttachmentPathById({ attachmentsDir, attachmentId })).toBe(archivePath);
122 } finally {
123 NodeFS.rmSync(attachmentsDir, { recursive: true, force: true });
124 }
125 });
126
95127 it("plans pending attachment claims with direct filename lookups", () => {
96128 const attachmentsDir = NodeFS.mkdtempSync(
97129 NodePath.join(NodeOS.tmpdir(), "t3code-attachment-claim-"),
......@@ -147,15 +179,17 @@ describe("attachmentStore", () => {
147179 const oldTimeSeconds = (now - 2 * 24 * 60 * 60 * 1000) / 1000;
148180 const uuid = "00000000-0000-4000-8000-000000000002";
149181 const pendingPath = NodePath.join(attachmentsDir, `pending-${uuid}.png`);
182 const pendingFilePath = NodePath.join(attachmentsDir, `pending-${uuid}-pdf.pdf`);
150183 const threadPath = NodePath.join(attachmentsDir, `thread-1-${uuid}.png`);
151184 const partialPath = NodePath.join(attachmentsDir, `${uuid}.part`);
152 for (const filePath of [pendingPath, threadPath, partialPath]) {
185 for (const filePath of [pendingPath, pendingFilePath, threadPath, partialPath]) {
153186 NodeFS.writeFileSync(filePath, Buffer.from("pixels"));
154187 NodeFS.utimesSync(filePath, oldTimeSeconds, oldTimeSeconds);
155188 }
156189
157 expect(sweepStalePendingAttachments({ attachmentsDir, nowMs: now })).toEqual({ deleted: 2 });
190 expect(sweepStalePendingAttachments({ attachmentsDir, nowMs: now })).toEqual({ deleted: 3 });
158191 expect(NodeFS.existsSync(pendingPath)).toBe(false);
192 expect(NodeFS.existsSync(pendingFilePath)).toBe(false);
159193 expect(NodeFS.existsSync(partialPath)).toBe(false);
160194 expect(NodeFS.existsSync(threadPath)).toBe(true);
161195 } finally {
apps/server/src/attachmentStore.ts+55-8
......@@ -15,8 +15,9 @@ const ATTACHMENT_FILENAME_EXTENSIONS = [...SAFE_IMAGE_FILE_EXTENSIONS, ".bin"];
1515const ATTACHMENT_ID_THREAD_SEGMENT_MAX_CHARS = 80;
1616const ATTACHMENT_ID_THREAD_SEGMENT_PATTERN = "[a-z0-9_]+(?:-[a-z0-9_]+)*";
1717const ATTACHMENT_ID_UUID_PATTERN = "[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}";
18const ATTACHMENT_ID_FILE_EXTENSION_PATTERN = "[a-z0-9]{1,10}";
1819const ATTACHMENT_ID_PATTERN = new RegExp(
19 `^(${ATTACHMENT_ID_THREAD_SEGMENT_PATTERN})-(${ATTACHMENT_ID_UUID_PATTERN})$`,
20 `^(${ATTACHMENT_ID_THREAD_SEGMENT_PATTERN})-(${ATTACHMENT_ID_UUID_PATTERN})(?:-(${ATTACHMENT_ID_FILE_EXTENSION_PATTERN}))?$`,
2021 "i",
2122);
2223
......@@ -39,8 +40,28 @@ export function toSafeThreadAttachmentSegment(threadId: string): string | null {
3940 return segment === PENDING_ATTACHMENT_THREAD_SEGMENT ? "_pending" : segment;
4041}
4142
42export function createPendingAttachmentId(): string {
43 return `${PENDING_ATTACHMENT_THREAD_SEGMENT}-${NodeCrypto.randomUUID()}`;
43export function attachmentFileExtension(fileName: string): string {
44 const extension = NodePath.extname(fileName).toLowerCase();
45 // ".part" is reserved for in-flight uploads; a stored "archive.part" would
46 // look stale to sweepStalePendingAttachments and get deleted.
47 if (extension === ".part" || !/^\.[a-z0-9]{1,10}$/.test(extension)) {
48 return ".bin";
49 }
50 return extension;
51}
52
53function attachmentIdExtensionSuffix(extension: string | undefined): string {
54 if (!extension) {
55 return "";
56 }
57 const normalized = extension.replace(/^\./, "").toLowerCase();
58 return new RegExp(`^${ATTACHMENT_ID_FILE_EXTENSION_PATTERN}$`).test(normalized)
59 ? `-${normalized}`
60 : "-bin";
61}
62
63export function createPendingAttachmentId(extension?: string): string {
64 return `${PENDING_ATTACHMENT_THREAD_SEGMENT}-${NodeCrypto.randomUUID()}${attachmentIdExtensionSuffix(extension)}`;
4465}
4566
4667export function parseAttachmentUuid(attachmentId: string): string | null {
......@@ -51,12 +72,20 @@ export function parseAttachmentUuid(attachmentId: string): string | null {
5172 return normalizedId.match(ATTACHMENT_ID_PATTERN)?.[2]?.toLowerCase() ?? null;
5273}
5374
54export function createAttachmentId(threadId: string): string | null {
75export function parseAttachmentFileExtension(attachmentId: string): string | null {
76 const normalizedId = normalizeAttachmentRelativePath(attachmentId);
77 if (!normalizedId || normalizedId.includes("/") || normalizedId.includes(".")) {
78 return null;
79 }
80 return normalizedId.match(ATTACHMENT_ID_PATTERN)?.[3]?.toLowerCase() ?? null;
81}
82
83export function createAttachmentId(threadId: string, extension?: string): string | null {
5584 const threadSegment = toSafeThreadAttachmentSegment(threadId);
5685 if (!threadSegment) {
5786 return null;
5887 }
59 return `${threadSegment}-${NodeCrypto.randomUUID()}`;
88 return `${threadSegment}-${NodeCrypto.randomUUID()}${attachmentIdExtensionSuffix(extension)}`;
6089}
6190
6291export function parseThreadSegmentFromAttachmentId(attachmentId: string): string | null {
......@@ -71,7 +100,8 @@ export function parseThreadSegmentFromAttachmentId(attachmentId: string): string
71100 return match[1]?.toLowerCase() ?? null;
72101}
73102
74export function attachmentRelativePath(attachment: ChatAttachment): string {
103/** Null for attachment types this build does not know; callers skip those. */
104export function attachmentRelativePath(attachment: ChatAttachment): string | null {
75105 switch (attachment.type) {
76106 case "image": {
77107 const extension = inferImageExtension({
......@@ -80,6 +110,10 @@ export function attachmentRelativePath(attachment: ChatAttachment): string {
80110 });
81111 return `${attachment.id}${extension}`;
82112 }
113 case "file":
114 return `${attachment.id}${attachmentFileExtension(attachment.name)}`;
115 default:
116 return null;
83117 }
84118}
85119
......@@ -87,9 +121,13 @@ export function resolveAttachmentPath(input: {
87121 readonly attachmentsDir: string;
88122 readonly attachment: ChatAttachment;
89123}): string | null {
124 const relativePath = attachmentRelativePath(input.attachment);
125 if (!relativePath) {
126 return null;
127 }
90128 return resolveAttachmentRelativePath({
91129 attachmentsDir: input.attachmentsDir,
92 relativePath: attachmentRelativePath(input.attachment),
130 relativePath,
93131 });
94132}
95133
......@@ -101,6 +139,14 @@ export function resolveAttachmentPathById(input: {
101139 if (!normalizedId || normalizedId.includes("/") || normalizedId.includes(".")) {
102140 return null;
103141 }
142 const fileExtension = parseAttachmentFileExtension(normalizedId);
143 if (fileExtension) {
144 const filePath = resolveAttachmentRelativePath({
145 attachmentsDir: input.attachmentsDir,
146 relativePath: `${normalizedId}.${fileExtension.toLowerCase()}`,
147 });
148 return filePath && NodeFS.existsSync(filePath) ? filePath : null;
149 }
104150 for (const extension of ATTACHMENT_FILENAME_EXTENSIONS) {
105151 const maybePath = resolveAttachmentRelativePath({
106152 attachmentsDir: input.attachmentsDir,
......@@ -147,7 +193,8 @@ export function planAttachmentClaim(input: {
147193 if (!currentPath) {
148194 return { ok: false, reason: "attachment not found (removed or expired)" };
149195 }
150 const finalId = createAttachmentId(input.threadId);
196 const fileExtension = parseAttachmentFileExtension(input.attachmentId) ?? undefined;
197 const finalId = createAttachmentId(input.threadId, fileExtension);
151198 if (!finalId) {
152199 return { ok: false, reason: "failed to create attachment id" };
153200 }
apps/server/src/environment/ServerEnvironment.test.ts+1
......@@ -91,6 +91,7 @@ it.layer(NodeServices.layer)("ServerEnvironmentLive", (it) => {
9191 expect(second.capabilities.repositoryIdentity).toBe(true);
9292 expect(second.capabilities.connectionProbe).toBe(true);
9393 expect(second.capabilities.attachmentUploads).toBe(true);
94 expect(second.capabilities.fileAttachments).toEqual({ maxUploadBytes: 50 * 1024 * 1024 });
9495 expect(second.capabilities.pullRequests).toBe(true);
9596 expect(second.capabilities.threadTitleRegeneration).toBe(true);
9697 expect(second.capabilities.threadPullRequestLinking).toBe(true);
apps/server/src/environment/ServerEnvironment.ts+6-1
......@@ -1,4 +1,8 @@
1import { EnvironmentId, type ExecutionEnvironmentDescriptor } from "@t3tools/contracts";
1import {
2 EnvironmentId,
3 PROVIDER_SEND_TURN_MAX_FILE_BYTES,
4 type ExecutionEnvironmentDescriptor,
5} from "@t3tools/contracts";
26import { HostProcessArchitecture, HostProcessPlatform } from "@t3tools/shared/hostProcess";
37import * as Context from "effect/Context";
48import * as Crypto from "effect/Crypto";
......@@ -147,6 +151,7 @@ export const make = Effect.gen(function* () {
147151 repositoryIdentity: true,
148152 connectionProbe: true,
149153 attachmentUploads: true,
154 fileAttachments: { maxUploadBytes: PROVIDER_SEND_TURN_MAX_FILE_BYTES },
150155 pullRequests: true,
151156 threadSettlement: true,
152157 threadSnooze: true,
apps/server/src/http.test.ts+81-1
......@@ -1,7 +1,12 @@
11import { expect, it } from "@effect/vitest";
22import { describe } from "vite-plus/test";
33
4import { assetResponseHeaders, isLoopbackHostname, resolveDevRedirectUrl } from "./http.ts";
4import {
5 assetResponseHeaders,
6 downloadContentDisposition,
7 isLoopbackHostname,
8 resolveDevRedirectUrl,
9} from "./http.ts";
510
611describe("http dev routing", () => {
712 it("treats localhost and loopback addresses as local", () => {
......@@ -55,4 +60,79 @@ describe("assetResponseHeaders", () => {
5560 "text/html; charset=utf-8",
5661 );
5762 });
63
64 it("downloads uploaded documents without executing their content", () => {
65 expect(assetResponseHeaders("/attachments/upload.html", { download: true })).toMatchObject({
66 "Content-Disposition": "attachment",
67 "Content-Security-Policy": "default-src 'none'; sandbox",
68 "Content-Type": "application/octet-stream",
69 });
70 });
71
72 it("serves the real filename and mime type when the claims carry them", () => {
73 expect(
74 assetResponseHeaders("/attachments/thread-1-abc-pdf.pdf", {
75 download: true,
76 fileName: "Q3 report.pdf",
77 mimeType: "application/pdf",
78 }),
79 ).toMatchObject({
80 "Content-Disposition": 'attachment; filename="Q3 report.pdf"',
81 "Content-Security-Policy": "default-src 'none'; sandbox",
82 "Content-Type": "application/pdf",
83 });
84 });
85
86 it("keeps renderable mime types as octet-stream downloads", () => {
87 for (const mimeType of [
88 "text/html",
89 "text/xml",
90 "image/svg+xml",
91 "application/xhtml+xml",
92 "application/rss+xml",
93 "APPLICATION/XML",
94 "IMAGE/SVG+XML",
95 "application/xml-dtd",
96 "application/xml-external-parsed-entity",
97 "not a mime",
98 ]) {
99 expect(
100 assetResponseHeaders("/attachments/upload.bin", { download: true, mimeType }),
101 ).toHaveProperty("Content-Type", "application/octet-stream");
102 }
103 });
104
105 it("preserves official Office Open XML mime types", () => {
106 for (const mimeType of [
107 "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
108 "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
109 "application/vnd.openxmlformats-officedocument.presentationml.presentation",
110 ]) {
111 expect(
112 assetResponseHeaders("/attachments/upload.bin", { download: true, mimeType }),
113 ).toHaveProperty("Content-Type", mimeType);
114 }
115 });
116});
117
118describe("downloadContentDisposition", () => {
119 it("quotes plain names and strips quotes and control characters", () => {
120 expect(downloadContentDisposition("report.pdf")).toBe('attachment; filename="report.pdf"');
121 expect(downloadContentDisposition('we"ird\n.pdf')).toBe('attachment; filename="we_ird_.pdf"');
122 });
123
124 it("adds an RFC 5987 encoded name for non-ASCII filenames", () => {
125 expect(downloadContentDisposition("répört.pdf")).toBe(
126 `attachment; filename="r_p_rt.pdf"; filename*=UTF-8''r%C3%A9p%C3%B6rt.pdf`,
127 );
128 expect(downloadContentDisposition("résumé'(*).pdf")).toBe(
129 `attachment; filename="r_sum_'(*).pdf"; filename*=UTF-8''r%C3%A9sum%C3%A9%27%28%2A%29.pdf`,
130 );
131 });
132
133 it("does not throw on unpaired surrogates in the filename", () => {
134 expect(downloadContentDisposition("bad\ud800name.pdf")).toBe(
135 `attachment; filename="bad_name.pdf"; filename*=UTF-8''bad%EF%BF%BDname.pdf`,
136 );
137 });
58138});
apps/server/src/http.ts+61-15
......@@ -12,6 +12,7 @@ import * as FileSystem from "effect/FileSystem";
1212import * as Layer from "effect/Layer";
1313import * as Option from "effect/Option";
1414import * as Path from "effect/Path";
15import * as Stream from "effect/Stream";
1516import { cast } from "effect/Function";
1617import {
1718 HttpBody,
......@@ -50,15 +51,57 @@ const LOOPBACK_HOSTNAMES = new Set(["127.0.0.1", "::1", "localhost"]);
5051const DESKTOP_RENDERER_ORIGINS = ["t3code://app", "t3code-dev://app"];
5152const SVG_CONTENT_SECURITY_POLICY = "default-src 'none'; style-src 'unsafe-inline'; sandbox";
5253
53export function assetResponseHeaders(filePath: string): Record<string, string> {
54// Types a browser may render as a document if a proxy strips the disposition
55// header. Downloads of these fall back to octet-stream.
56const DOWNLOAD_MIME_TYPE_PATTERN = /^[\w!#$&^.+-]+\/[\w!#$&^.+-]+$/;
57const isSafeDownloadMimeType = (mimeType: string): boolean =>
58 DOWNLOAD_MIME_TYPE_PATTERN.test(mimeType) &&
59 !/(?:^text\/html$|\/xml(?:$|-)|\+xml$)/i.test(mimeType.trim().toLowerCase());
60
61/** RFC 6266 disposition with an ASCII fallback name plus a UTF-8 `filename*`. */
62export function downloadContentDisposition(fileName?: string): string {
63 if (fileName === undefined) {
64 return "attachment";
65 }
66 // toWellFormed: encodeURIComponent throws URIError on unpaired surrogates.
67 // eslint-disable-next-line no-control-regex -- Header filenames must strip ASCII controls.
68 const sanitized = fileName.toWellFormed().replace(/[\u0000-\u001f"\\]/g, "_");
69 const asciiFallback = sanitized.replace(/[^\u0020-\u007e]/g, "_");
70 const needsExtended = asciiFallback !== sanitized;
71 const extendedName = encodeURIComponent(sanitized).replace(
72 /['()*]/g,
73 (character) => `%${character.charCodeAt(0).toString(16).toUpperCase()}`,
74 );
75 return `attachment; filename="${asciiFallback}"${
76 needsExtended ? `; filename*=UTF-8''${extendedName}` : ""
77 }`;
78}
79
80export function assetResponseHeaders(
81 filePath: string,
82 options?: {
83 readonly download?: boolean;
84 readonly fileName?: string;
85 readonly mimeType?: string;
86 },
87): Record<string, string> {
5488 const lowerPath = filePath.toLowerCase();
5589 return {
5690 "Cache-Control": "private, max-age=3600",
5791 "X-Content-Type-Options": "nosniff",
58 ...(lowerPath.endsWith(".html") || lowerPath.endsWith(".htm")
59 ? { "Content-Type": "text/html; charset=utf-8" }
60 : {}),
61 ...(lowerPath.endsWith(".svg")
92 ...(options?.download
93 ? {
94 "Content-Disposition": downloadContentDisposition(options.fileName),
95 "Content-Security-Policy": "default-src 'none'; sandbox",
96 "Content-Type":
97 options.mimeType !== undefined && isSafeDownloadMimeType(options.mimeType)
98 ? options.mimeType
99 : "application/octet-stream",
100 }
101 : lowerPath.endsWith(".html") || lowerPath.endsWith(".htm")
102 ? { "Content-Type": "text/html; charset=utf-8" }
103 : {}),
104 ...(!options?.download && lowerPath.endsWith(".svg")
62105 ? { "Content-Security-Policy": SVG_CONTENT_SECURITY_POLICY }
63106 : {}),
64107 };
......@@ -228,7 +271,16 @@ export const assetRouteLayer = HttpRouter.add(
228271 }
229272 return yield* HttpServerResponse.file(asset.path, {
230273 status: 200,
231 headers: assetResponseHeaders(asset.path),
274 headers: assetResponseHeaders(
275 asset.path,
276 asset.download
277 ? {
278 download: true,
279 ...(asset.fileName !== undefined ? { fileName: asset.fileName } : {}),
280 ...(asset.mimeType !== undefined ? { mimeType: asset.mimeType } : {}),
281 }
282 : undefined,
283 ),
232284 }).pipe(
233285 Effect.orElseSucceed(() => HttpServerResponse.text("Internal Server Error", { status: 500 })),
234286 );
......@@ -265,15 +317,9 @@ export const attachmentUploadRouteLayer = HttpRouter.add(
265317 });
266318 }
267319
268 const body = yield* request.arrayBuffer.pipe(
269 Effect.provideService(HttpServerRequest.MaxBodySize, FileSystem.Size(claims.sizeBytes)),
270 Effect.orElseSucceed(() => null),
271 );
272 if (body === null) {
273 return HttpServerResponse.text("Failed to read the upload body.", { status: 400 });
274 }
275
276 const stored = yield* storeAttachmentUpload(claims, new Uint8Array(body));
320 // Keep the request stream in the route scope until the response is sent.
321 const bodyPull = yield* Stream.toPull(request.stream);
322 const stored = yield* storeAttachmentUpload(claims, Stream.fromPull(Effect.succeed(bodyPull)));
277323 return stored.ok
278324 ? HttpServerResponse.empty({ status: 204 })
279325 : HttpServerResponse.text(stored.detail, { status: stored.status });
apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts+23
......@@ -882,6 +882,7 @@ it.layer(
882882 const now = "2026-01-01T00:00:00.000Z";
883883 const threadId = ThreadId.make("Thread Revert.Files");
884884 const keepAttachmentId = "thread-revert-files-00000000-0000-4000-8000-000000000001";
885 const keepFileAttachmentId = "thread-revert-files-00000000-0000-4000-8000-000000000004-pdf";
885886 const removeAttachmentId = "thread-revert-files-00000000-0000-4000-8000-000000000002";
886887 const otherThreadAttachmentId =
887888 "thread-revert-files-extra-00000000-0000-4000-8000-000000000003";
......@@ -983,6 +984,13 @@ it.layer(
983984 mimeType: "image/png",
984985 sizeBytes: 5,
985986 },
987 {
988 type: "file",
989 id: keepFileAttachmentId,
990 name: "keep.pdf",
991 mimeType: "application/pdf",
992 sizeBytes: 5,
993 },
986994 ],
987995 turnId: TurnId.make("turn-keep"),
988996 streaming: false,
......@@ -1045,9 +1053,11 @@ it.layer(
10451053 });
10461054
10471055 const keepPath = path.join(attachmentsDir, `${keepAttachmentId}.png`);
1056 const keepFilePath = path.join(attachmentsDir, `${keepFileAttachmentId}.pdf`);
10481057 const removePath = path.join(attachmentsDir, `${removeAttachmentId}.png`);
10491058 yield* fileSystem.makeDirectory(attachmentsDir, { recursive: true });
10501059 yield* fileSystem.writeFileString(keepPath, "keep");
1060 yield* fileSystem.writeFileString(keepFilePath, "keep");
10511061 yield* fileSystem.writeFileString(removePath, "remove");
10521062 const otherThreadPath = path.join(attachmentsDir, `${otherThreadAttachmentId}.png`);
10531063 yield* fileSystem.writeFileString(otherThreadPath, "other");
......@@ -1072,6 +1082,7 @@ it.layer(
10721082 });
10731083
10741084 assert.isTrue(yield* exists(keepPath));
1085 assert.isTrue(yield* exists(keepFilePath));
10751086 assert.isFalse(yield* exists(removePath));
10761087 assert.isTrue(yield* exists(otherThreadPath));
10771088 }),
......@@ -1091,6 +1102,7 @@ it.layer(Layer.fresh(makeProjectionPipelinePrefixedTestLayer("t3-projection-atta
10911102 const now = "2026-01-01T00:00:00.000Z";
10921103 const threadId = ThreadId.make("Thread Delete.Files");
10931104 const attachmentId = "thread-delete-files-00000000-0000-4000-8000-000000000001";
1105 const fileAttachmentId = "thread-delete-files-00000000-0000-4000-8000-000000000003-pdf";
10941106 const otherThreadAttachmentId =
10951107 "thread-delete-files-extra-00000000-0000-4000-8000-000000000002";
10961108
......@@ -1169,6 +1181,13 @@ it.layer(Layer.fresh(makeProjectionPipelinePrefixedTestLayer("t3-projection-atta
11691181 mimeType: "image/png",
11701182 sizeBytes: 5,
11711183 },
1184 {
1185 type: "file",
1186 id: fileAttachmentId,
1187 name: "delete.pdf",
1188 mimeType: "application/pdf",
1189 sizeBytes: 6,
1190 },
11721191 ],
11731192 turnId: null,
11741193 streaming: false,
......@@ -1178,14 +1197,17 @@ it.layer(Layer.fresh(makeProjectionPipelinePrefixedTestLayer("t3-projection-atta
11781197 });
11791198
11801199 const threadAttachmentPath = path.join(attachmentsDir, `${attachmentId}.png`);
1200 const threadFileAttachmentPath = path.join(attachmentsDir, `${fileAttachmentId}.pdf`);
11811201 const otherThreadAttachmentPath = path.join(
11821202 attachmentsDir,
11831203 `${otherThreadAttachmentId}.png`,
11841204 );
11851205 yield* fileSystem.makeDirectory(attachmentsDir, { recursive: true });
11861206 yield* fileSystem.writeFileString(threadAttachmentPath, "delete");
1207 yield* fileSystem.writeFileString(threadFileAttachmentPath, "delete");
11871208 yield* fileSystem.writeFileString(otherThreadAttachmentPath, "other-thread");
11881209 assert.isTrue(yield* exists(threadAttachmentPath));
1210 assert.isTrue(yield* exists(threadFileAttachmentPath));
11891211 assert.isTrue(yield* exists(otherThreadAttachmentPath));
11901212
11911213 yield* appendAndProject({
......@@ -1205,6 +1227,7 @@ it.layer(Layer.fresh(makeProjectionPipelinePrefixedTestLayer("t3-projection-atta
12051227 });
12061228
12071229 assert.isFalse(yield* exists(threadAttachmentPath));
1230 assert.isFalse(yield* exists(threadFileAttachmentPath));
12081231 assert.isTrue(yield* exists(otherThreadAttachmentPath));
12091232 }),
12101233 );
apps/server/src/orchestration/Layers/ProjectionPipeline.ts+4-4
......@@ -361,14 +361,14 @@ function collectThreadAttachmentRelativePaths(
361361 const relativePaths = new Set<string>();
362362 for (const message of messages) {
363363 for (const attachment of message.attachments ?? []) {
364 if (attachment.type !== "image") {
365 continue;
366 }
367364 const attachmentThreadSegment = parseThreadSegmentFromAttachmentId(attachment.id);
368365 if (!attachmentThreadSegment || attachmentThreadSegment !== threadSegment) {
369366 continue;
370367 }
371 relativePaths.add(attachmentRelativePath(attachment));
368 const relativePath = attachmentRelativePath(attachment);
369 if (relativePath) {
370 relativePaths.add(relativePath);
371 }
372372 }
373373 }
374374 return relativePaths;
apps/server/src/orchestration/Normalizer.attachments.test.ts+46-4
......@@ -93,9 +93,12 @@ describe("normalizeDispatchCommand attachments", () => {
9393 expect(attachmentId.startsWith("thread-1-")).toBe(true);
9494 expect(attachmentId).not.toBe(`thread-1-${attachmentUuid}`);
9595 expect(NodeFS.existsSync(pendingPath)).toBe(true);
96 expect(NodeFS.existsSync(NodePath.join(config.attachmentsDir, `${attachmentId}.png`))).toBe(
97 true,
98 );
96 const claimedPngPath = NodePath.join(config.attachmentsDir, `${attachmentId}.png`);
97 expect(NodeFS.existsSync(claimedPngPath)).toBe(true);
98 // A copy, not a hard link: editing the delivered file must not mutate
99 // the retryable pending upload.
100 expect(NodeFS.statSync(claimedPngPath).ino).not.toBe(NodeFS.statSync(pendingPath).ino);
101 expect(NodeFS.readFileSync(claimedPngPath)).toEqual(bytes);
99102 }).pipe(Effect.provide(testLayer)),
100103 );
101104
......@@ -124,6 +127,45 @@ describe("normalizeDispatchCommand attachments", () => {
124127 }).pipe(Effect.provide(testLayer)),
125128 );
126129
130 it.effect("claims uploaded documents without changing their original extension", () =>
131 Effect.gen(function* () {
132 const config = yield* ServerConfig.ServerConfig;
133 const pendingId = `pending-${attachmentUuid}-pdf`;
134 const pendingPath = NodePath.join(config.attachmentsDir, `${pendingId}.pdf`);
135 NodeFS.writeFileSync(pendingPath, Buffer.from("report"));
136
137 const imageCommand = turnStartCommand({ attachments: [] });
138 if (imageCommand.type !== "thread.turn.start") {
139 throw new Error("Expected a thread.turn.start command.");
140 }
141 const normalized = yield* normalizeDispatchCommand({
142 ...imageCommand,
143 message: {
144 ...imageCommand.message,
145 attachments: [
146 {
147 type: "file",
148 id: pendingId,
149 name: "report.pdf",
150 mimeType: "application/pdf",
151 sizeBytes: 6,
152 },
153 ],
154 },
155 });
156 if (normalized.type !== "thread.turn.start") {
157 throw new Error("Expected a thread.turn.start command.");
158 }
159
160 const attachment = normalized.message.attachments[0]!;
161 expect(attachment.type).toBe("file");
162 expect(attachment.id).toMatch(/^thread-1-.*-pdf$/);
163 const claimedPath = NodePath.join(config.attachmentsDir, `${attachment.id}.pdf`);
164 expect(NodeFS.readFileSync(claimedPath)).toEqual(Buffer.from("report"));
165 expect(NodeFS.statSync(claimedPath).ino).not.toBe(NodeFS.statSync(pendingPath).ino);
166 }).pipe(Effect.provide(testLayer)),
167 );
168
127169 it.effect("retries a failed bootstrap with a fresh thread id", () =>
128170 Effect.gen(function* () {
129171 const config = yield* ServerConfig.ServerConfig;
......@@ -312,7 +354,7 @@ describe("normalizeDispatchCommand attachments", () => {
312354 })),
313355 },
314356 }).pipe(Effect.flip);
315 expect(mismatchedType.message).toContain("image type");
357 expect(mismatchedType.message).toContain("attachment type");
316358 }).pipe(Effect.provide(testLayer)),
317359 );
318360});
apps/server/src/orchestration/Normalizer.ts+4-2
......@@ -176,12 +176,14 @@ export const normalizeDispatchCommand = (command: ClientOrchestrationCommand) =>
176176 });
177177 if (expectedPath !== claim.finalPath) {
178178 return yield* new OrchestrationDispatchCommandError({
179 message: `Attachment '${attachment.name}' cannot be sent: image type does not match the upload.`,
179 message: `Attachment '${attachment.name}' cannot be sent: attachment type does not match the upload.`,
180180 });
181181 }
182182
183183 // Keep the pending copy until the turn succeeds. A failed thread
184 // bootstrap can then retry with a fresh thread id.
184 // bootstrap can then retry with a fresh thread id. A copy, not a
185 // hard link: an agent editing the delivered file in place must not
186 // mutate the retry source.
185187 yield* fileSystem.copyFile(claim.currentPath, claim.finalPath).pipe(
186188 Effect.mapError(
187189 (cause) =>
apps/server/src/provider/Layers/ClaudeAdapter.test.ts+1-1
......@@ -808,7 +808,7 @@ describe("ClaudeAdapterLive", () => {
808808 mimeType: "image/png",
809809 sizeBytes: 4,
810810 };
811 const attachmentPath = NodePath.join(attachmentsDir, attachmentRelativePath(attachment));
811 const attachmentPath = NodePath.join(attachmentsDir, attachmentRelativePath(attachment)!);
812812 NodeFS.mkdirSync(NodePath.dirname(attachmentPath), { recursive: true });
813813 NodeFS.writeFileSync(attachmentPath, Uint8Array.from([1, 2, 3, 4]));
814814
apps/server/src/provider/Layers/ClaudeAdapter.ts+2
......@@ -1306,6 +1306,8 @@ const buildUserMessageEffect = Effect.fn("buildUserMessageEffect")(function* (
13061306 }
13071307
13081308 for (const attachment of input.attachments ?? []) {
1309 // Claude ingests images only. Generic files reach the agent through the
1310 // path line ProviderService puts in the prompt.
13091311 if (attachment.type !== "image") {
13101312 continue;
13111313 }
apps/server/src/provider/Layers/CodexAdapter.ts+4-1
......@@ -1822,8 +1822,11 @@ export const makeCodexAdapter = Effect.fn("makeCodexAdapter")(function* (
18221822 });
18231823
18241824 const sendTurn: CodexAdapterShape["sendTurn"] = Effect.fn("sendTurn")(function* (input) {
1825 // Codex ingests images only. Anything else would be base64-encoded as an
1826 // image and rejected or misread; generic files reach the agent through the
1827 // path line ProviderService puts in the prompt.
18251828 const codexAttachments = yield* Effect.forEach(
1826 input.attachments ?? [],
1829 (input.attachments ?? []).filter((attachment) => attachment.type === "image"),
18271830 (attachment) => resolveAttachment(input, attachment),
18281831 { concurrency: 1 },
18291832 );
apps/server/src/provider/Layers/CursorAdapter.ts+5
......@@ -972,6 +972,11 @@ export function makeCursorAdapter(
972972 }
973973 if (input.attachments && input.attachments.length > 0) {
974974 for (const attachment of input.attachments) {
975 // Cursor ingests images only. Generic files reach the agent
976 // through the path line ProviderService puts in the prompt.
977 if (attachment.type !== "image") {
978 continue;
979 }
975980 const attachmentPath = resolveAttachmentPath({
976981 attachmentsDir: serverConfig.attachmentsDir,
977982 attachment,
apps/server/src/provider/Layers/GrokAdapter.ts+3-1
......@@ -1504,8 +1504,10 @@ export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapte
15041504 );
15051505
15061506 const text = input.input?.trim();
1507 // Grok ingests images only. Generic files reach the agent
1508 // through the path line ProviderService puts in the prompt.
15071509 const imagePromptParts = yield* Effect.forEach(
1508 input.attachments ?? [],
1510 (input.attachments ?? []).filter((attachment) => attachment.type === "image"),
15091511 (attachment) =>
15101512 Effect.gen(function* () {
15111513 const attachmentPath = resolveAttachmentPath({
apps/server/src/provider/Layers/OpenCodeAdapter.ts+2
......@@ -1456,6 +1456,8 @@ export function makeOpenCodeAdapter(
14561456 }
14571457
14581458 const text = input.input?.trim();
1459 // OpenCode ingests images, text, and PDFs natively; formats its model
1460 // paths reject ride only as the prompt's file path line.
14591461 const fileParts = toOpenCodeFileParts({
14601462 attachments: input.attachments,
14611463 resolveAttachmentPath: (attachment) =>
apps/server/src/provider/Layers/ProviderService.test.ts+27-1
......@@ -14,7 +14,6 @@ import type {
1414} from "@t3tools/contracts";
1515import {
1616 ApprovalRequestId,
17 EnvironmentId,
1817 EventId,
1918 ProviderDriverKind,
2019 ProviderInstanceId,
......@@ -1145,6 +1144,33 @@ routing.layer("ProviderServiceLive routing", (it) => {
11451144 const imageOnlyInput = routing.codex.sendTurn.mock.calls[0]?.[0] as ProviderSendTurnInput;
11461145 assert.equal(imageOnlyInput.input?.startsWith('[Attached image "screenshot.png"'), true);
11471146
1147 const fileAttachment = {
1148 type: "file" as const,
1149 id: "thread-attach-12345678-1234-1234-1234-123456789abc-pdf",
1150 name: "report.pdf",
1151 mimeType: "application/pdf",
1152 sizeBytes: 456,
1153 };
1154
1155 routing.codex.sendTurn.mockClear();
1156 yield* provider.sendTurn({
1157 threadId: session.threadId,
1158 input: "summarize the report",
1159 attachments: [attachment, fileAttachment],
1160 });
1161 const mixedInput = routing.codex.sendTurn.mock.calls[0]?.[0] as ProviderSendTurnInput;
1162 assert.include(mixedInput.input ?? "", '[Attached file "report.pdf" is saved at: ');
1163 assert.include(mixedInput.input ?? "", `${fileAttachment.id}.pdf]`);
1164 // Every attachment reaches the adapter; each adapter decides what its
1165 // provider ingests natively.
1166 assert.deepEqual(mixedInput.attachments, [attachment, fileAttachment]);
1167
1168 routing.codex.sendTurn.mockClear();
1169 yield* provider.sendTurn({ threadId: session.threadId, attachments: [fileAttachment] });
1170 const fileOnlyInput = routing.codex.sendTurn.mock.calls[0]?.[0] as ProviderSendTurnInput;
1171 assert.include(fileOnlyInput.input ?? "", '[Attached file "report.pdf" is saved at: ');
1172 assert.deepEqual(fileOnlyInput.attachments, [fileAttachment]);
1173
11481174 yield* provider.stopSession({ threadId: session.threadId });
11491175 }),
11501176 );
apps/server/src/provider/Layers/ProviderService.ts+8-10
......@@ -729,13 +729,12 @@ const makeProviderService = Effect.fn("makeProviderService")(function* (
729729 );
730730 }
731731
732 // Adapters inline attachment pixels into the model prompt, but the model's
733 // tools cannot dereference pixels. Appending the on-disk path is what lets
734 // a turn like "include this screenshot in the PR" copy the actual file.
735 // This runs after schema decode, so the appended lines are exempt from the
736 // PROVIDER_SEND_TURN_MAX_INPUT_CHARS check; attachment count is capped, so
737 // the overhead is bounded. Unresolvable ids are skipped here and surface
738 // as adapter errors when the file is read for inlining.
732 // Every attachment gets an on-disk path in the prompt so the model's tools
733 // can dereference the actual file. All attachments then go to the adapter,
734 // and each adapter decides what its provider ingests natively: OpenCode
735 // sends generic files as file parts, the others send images only and rely
736 // on the path line for everything else. Unresolvable ids are skipped here
737 // and surface as adapter errors when the file is read.
739738 const attachmentPathLines = attachments.flatMap((attachment) => {
740739 const attachmentPath = resolveAttachmentPath({
741740 attachmentsDir: serverConfig.attachmentsDir,
......@@ -757,13 +756,12 @@ const makeProviderService = Effect.fn("makeProviderService")(function* (
757756 ...(inputTextWithAttachmentPaths !== undefined
758757 ? { input: inputTextWithAttachmentPaths }
759758 : {}),
760 attachments,
761759 };
762760 yield* Effect.annotateCurrentSpan({
763761 "provider.operation": "send-turn",
764762 "provider.thread_id": input.threadId,
765763 "provider.interaction_mode": input.interactionMode,
766 "provider.attachment_count": input.attachments.length,
764 "provider.attachment_count": attachments.length,
767765 });
768766 let metricProvider = "unknown";
769767 let metricModel = input.modelSelection?.model;
......@@ -807,7 +805,7 @@ const makeProviderService = Effect.fn("makeProviderService")(function* (
807805 // often, since every toggle restarts the session. Recording it per turn
808806 // gives a usage-weighted view and lets it cross with interactionMode.
809807 runtimeMode: routed.runtimeMode,
810 attachmentCount: input.attachments.length,
808 attachmentCount: attachments.length,
811809 hasInput: typeof input.input === "string" && input.input.trim().length > 0,
812810 });
813811 return turn;
apps/server/src/provider/opencodeRuntime.cliParsers.test.ts+36
......@@ -6,6 +6,7 @@ import {
66 parseAgentListCliOutput,
77 parseModelsCliOutput,
88 parseSkillsCliOutput,
9 toOpenCodeFileParts,
910} from "./opencodeRuntime.ts";
1011
1112describe("parseModelsCliOutput", () => {
......@@ -283,3 +284,38 @@ describe("parseSkillsCliOutput", () => {
283284 NodeAssert.deepEqual(parseSkillsCliOutput("not json"), []);
284285 });
285286});
287
288describe("toOpenCodeFileParts", () => {
289 const attachment = (mimeType: string, sizeBytes = 12) => ({
290 type: "file" as const,
291 id: "thread-1-00000000-0000-4000-8000-000000000001-bin",
292 name: "attachment",
293 mimeType,
294 sizeBytes,
295 });
296
297 it("sends supported images, text, and PDFs natively and skips what models reject", () => {
298 const parts = toOpenCodeFileParts({
299 attachments: [
300 attachment("application/pdf"),
301 attachment("text/markdown"),
302 attachment("image/png"),
303 // A ZIP file part makes OpenCode's Anthropic path throw before the
304 // turn starts; it must ride only as the prompt's file path line.
305 attachment("application/zip"),
306 attachment("application/octet-stream"),
307 // Image formats the model APIs reject stay on the fallback path too.
308 attachment("image/bmp"),
309 attachment("image/svg+xml"),
310 // Over the direct-attachment limit: path fallback even for a PDF.
311 attachment("application/pdf", 21 * 1024 * 1024),
312 ],
313 resolveAttachmentPath: () => "/tmp/attachment",
314 });
315
316 NodeAssert.deepEqual(
317 parts.map((part) => part.mime),
318 ["application/pdf", "text/markdown", "image/png"],
319 );
320 });
321});
apps/server/src/provider/opencodeRuntime.ts+28
......@@ -345,6 +345,31 @@ export function openCodeQuestionId(
345345 return header.length > 0 ? `question-${index}-${header}` : `question-${index}`;
346346}
347347
348/**
349 * Attachments OpenCode can hand to a model as a native file part. Anything
350 * else (ZIP, binaries, image formats like BMP/AVIF/SVG that model APIs
351 * reject, or files over the direct-attachment size limit) would make the turn
352 * fail before it starts, so those ride only as the file path ProviderService
353 * puts in the prompt.
354 */
355const OPENCODE_NATIVE_IMAGE_MIMES = new Set(["image/png", "image/jpeg", "image/gif", "image/webp"]);
356export const OPENCODE_NATIVE_FILE_PART_MAX_BYTES = 20 * 1024 * 1024;
357
358export function isOpenCodeNativeFilePart(input: {
359 readonly mimeType: string;
360 readonly sizeBytes: number;
361}): boolean {
362 if (input.sizeBytes > OPENCODE_NATIVE_FILE_PART_MAX_BYTES) {
363 return false;
364 }
365 const normalized = input.mimeType.trim().toLowerCase();
366 return (
367 OPENCODE_NATIVE_IMAGE_MIMES.has(normalized) ||
368 normalized.startsWith("text/") ||
369 normalized === "application/pdf"
370 );
371}
372
348373export function toOpenCodeFileParts(input: {
349374 readonly attachments: ReadonlyArray<ChatAttachment> | undefined;
350375 readonly resolveAttachmentPath: (attachment: ChatAttachment) => string | null;
......@@ -352,6 +377,9 @@ export function toOpenCodeFileParts(input: {
352377 const parts: Array<FilePartInput> = [];
353378
354379 for (const attachment of input.attachments ?? []) {
380 if (!isOpenCodeNativeFilePart(attachment)) {
381 continue;
382 }
355383 const attachmentPath = input.resolveAttachmentPath(attachment);
356384 if (!attachmentPath) {
357385 continue;
apps/server/src/server.test.ts+107
......@@ -4579,6 +4579,113 @@ it.layer(NodeServices.layer)("server router seam", (it) => {
45794579 });
45804580 assert.equal(streamedResponse.status, 204);
45814581 yield* client[WS_METHODS.attachmentsDelete]({ attachmentId: streamed.attachmentId });
4582
4583 const uploadedFile = yield* client[WS_METHODS.attachmentsCreateUploadUrl]({
4584 type: "file",
4585 name: "report.pdf",
4586 mimeType: "application/pdf",
4587 sizeBytes: 6,
4588 });
4589 const fileResponse = yield* HttpClient.post(uploadedFile.relativeUrl, {
4590 body: HttpBody.stream(
4591 Stream.make(new Uint8Array([1, 2, 3]), new Uint8Array([4, 5, 6])),
4592 "application/pdf",
4593 ),
4594 });
4595 assert.equal(fileResponse.status, 204);
4596 const uploadedFilePath = path.join(
4597 config.attachmentsDir,
4598 `${uploadedFile.attachmentId}.pdf`,
4599 );
4600 assert.isTrue(yield* fileSystem.exists(uploadedFilePath));
4601
4602 // A mint that carries the attachment's display name and mime
4603 // serves a real download filename and Content-Type.
4604 const download = yield* client[WS_METHODS.assetsCreateUrl]({
4605 resource: {
4606 _tag: "attachment",
4607 attachmentId: uploadedFile.attachmentId,
4608 fileName: "report.pdf",
4609 mimeType: "application/pdf",
4610 },
4611 });
4612 const downloadResponse = yield* HttpClient.get(download.relativeUrl);
4613 assert.equal(downloadResponse.status, 200);
4614 assert.equal(
4615 downloadResponse.headers["content-disposition"],
4616 'attachment; filename="report.pdf"',
4617 );
4618 assert.equal(downloadResponse.headers["content-type"], "application/pdf");
4619
4620 // Old clients mint without name or mime and still get a download.
4621 const bareDownload = yield* client[WS_METHODS.assetsCreateUrl]({
4622 resource: { _tag: "attachment", attachmentId: uploadedFile.attachmentId },
4623 });
4624 const bareResponse = yield* HttpClient.get(bareDownload.relativeUrl);
4625 assert.equal(bareResponse.status, 200);
4626 assert.equal(bareResponse.headers["content-disposition"], "attachment");
4627 assert.equal(bareResponse.headers["content-type"], "application/octet-stream");
4628
4629 yield* client[WS_METHODS.attachmentsDelete]({
4630 attachmentId: uploadedFile.attachmentId,
4631 });
4632 assert.isFalse(yield* fileSystem.exists(uploadedFilePath));
4633 }),
4634 ),
4635 );
4636 }).pipe(Effect.provide(NodeHttpServer.layerTest)),
4637 );
4638
4639 it.effect("rejects an over-limit chunked upload through the route without hanging", () =>
4640 Effect.gen(function* () {
4641 const config = yield* buildAppUnderTest();
4642 const fileSystem = yield* FileSystem.FileSystem;
4643 const wsUrl = yield* getWsServerUrl("/ws");
4644
4645 yield* Effect.scoped(
4646 withWsRpcClient(wsUrl, (client) =>
4647 Effect.gen(function* () {
4648 const issued = yield* client[WS_METHODS.attachmentsCreateUploadUrl]({
4649 type: "file",
4650 name: "big.bin",
4651 mimeType: "application/octet-stream",
4652 sizeBytes: 6,
4653 });
4654 const NodeHttp = yield* Effect.promise(() => import("node:http"));
4655 const uploadUrl = new URL(issued.relativeUrl, yield* getHttpServerUrl());
4656 const status = yield* Effect.callback<number, Error>((resume) => {
4657 let completed = false;
4658 const complete = (result: Effect.Effect<number, Error>) => {
4659 if (completed) return;
4660 completed = true;
4661 resume(result);
4662 };
4663 const request = NodeHttp.request(
4664 uploadUrl,
4665 {
4666 method: "POST",
4667 headers: {
4668 "content-type": "application/octet-stream",
4669 "transfer-encoding": "chunked",
4670 },
4671 },
4672 (response) => {
4673 request.end();
4674 response.resume();
4675 response.once("end", () => complete(Effect.succeed(response.statusCode ?? 0)));
4676 response.once("error", (error) => complete(Effect.fail(error)));
4677 },
4678 );
4679 request.once("error", (error) => complete(Effect.fail(error)));
4680 request.flushHeaders();
4681 request.write(new Uint8Array(4), () => {
4682 request.write(new Uint8Array(4));
4683 });
4684
4685 return Effect.sync(() => request.destroy());
4686 });
4687 assert.equal(status, 400);
4688 assert.deepEqual(yield* fileSystem.readDirectory(config.attachmentsDir), []);
45824689 }),
45834690 ),
45844691 );
apps/server/src/textGeneration/OpenCodeTextGeneration.test.ts+43-1
......@@ -18,6 +18,7 @@ const runtimeMock = {
1818 state: {
1919 startCalls: [] as string[],
2020 promptUrls: [] as string[],
21 promptParts: [] as ReadonlyArray<unknown>[],
2122 authHeaders: [] as Array<string | null>,
2223 closeCalls: [] as string[],
2324 sessionCreateError: undefined as unknown,
......@@ -30,6 +31,7 @@ const runtimeMock = {
3031 reset() {
3132 this.state.startCalls.length = 0;
3233 this.state.promptUrls.length = 0;
34 this.state.promptParts.length = 0;
3335 this.state.authHeaders.length = 0;
3436 this.state.closeCalls.length = 0;
3537 this.state.sessionCreateError = undefined;
......@@ -73,8 +75,9 @@ const OpenCodeRuntimeTestDouble: OpenCodeRuntime.OpenCodeRuntimeShape = {
7375 }
7476 return runtimeMock.state.sessionResult ?? { data: { id: `${baseUrl}/session` } };
7577 },
76 prompt: async () => {
78 prompt: async (input: { readonly parts: ReadonlyArray<unknown> }) => {
7779 runtimeMock.state.promptUrls.push(baseUrl);
80 runtimeMock.state.promptParts.push(input.parts);
7881 runtimeMock.state.authHeaders.push(
7982 serverPassword ? `Basic ${btoa(`opencode:${serverPassword}`)}` : null,
8083 );
......@@ -187,6 +190,45 @@ const advanceIdleClock = Effect.gen(function* () {
187190});
188191
189192it.layer(OpenCodeTextGenerationTestLayer)("OpenCodeTextGeneration", (it) => {
193 it.effect("excludes generic files from thread title generation", () =>
194 withOpenCodeTextGeneration(DEFAULT_OPENCODE_SETTINGS, (textGeneration) =>
195 Effect.gen(function* () {
196 runtimeMock.state.promptResult = {
197 data: {
198 parts: [{ type: "text", text: '{"title":"Review uploaded report"}' }],
199 },
200 };
201
202 yield* textGeneration.generateThreadTitle({
203 cwd: process.cwd(),
204 message: "Review these attachments.",
205 modelSelection: DEFAULT_TEST_MODEL_SELECTION,
206 attachments: [
207 {
208 type: "image",
209 id: "thread-image-attachment",
210 name: "screenshot.png",
211 mimeType: "image/png",
212 sizeBytes: 3,
213 },
214 {
215 type: "file",
216 id: "thread-report-attachment-pdf",
217 name: "report.pdf",
218 mimeType: "application/pdf",
219 sizeBytes: 42,
220 },
221 ],
222 });
223
224 expect(runtimeMock.state.promptParts[0]).toEqual([
225 expect.objectContaining({ type: "text" }),
226 expect.objectContaining({ type: "file", filename: "screenshot.png" }),
227 ]);
228 }),
229 ),
230 );
231
190232 it.effect("reuses a warm server across back-to-back requests and closes it after idling", () =>
191233 withOpenCodeTextGeneration(DEFAULT_OPENCODE_SETTINGS, (textGeneration) =>
192234 Effect.gen(function* () {
apps/server/src/textGeneration/OpenCodeTextGeneration.ts+1-1
......@@ -375,7 +375,7 @@ export const makeOpenCodeTextGeneration = Effect.fn("makeOpenCodeTextGeneration"
375375 }
376376
377377 const fileParts = OpenCodeRuntime.toOpenCodeFileParts({
378 attachments: input.attachments,
378 attachments: input.attachments?.filter((attachment) => attachment.type === "image"),
379379 resolveAttachmentPath: (attachment) =>
380380 resolveAttachmentPath({ attachmentsDir: serverConfig.attachmentsDir, attachment }),
381381 });
apps/web/src/components/ChatView.logic.ts+9-3
......@@ -11,7 +11,13 @@ import {
1111 type ThreadId,
1212 type TurnId,
1313} from "@t3tools/contracts";
14import { type ChatMessage, type SessionPhase, type Thread, type ThreadShell } from "../types";
14import {
15 type ChatMessage,
16 isImageAttachment,
17 type SessionPhase,
18 type Thread,
19 type ThreadShell,
20} from "../types";
1521import { type ComposerImageAttachment, type DraftThreadState } from "../composerDraftStore";
1622import * as Schema from "effect/Schema";
1723import { appAtomRegistry } from "../rpc/atomRegistry";
......@@ -277,7 +283,7 @@ export function revokeUserMessagePreviewUrls(message: ChatMessage): void {
277283 return;
278284 }
279285 for (const attachment of message.attachments) {
280 if (attachment.type !== "image") {
286 if (!isImageAttachment(attachment)) {
281287 continue;
282288 }
283289 revokeBlobPreviewUrl(attachment.previewUrl);
......@@ -290,7 +296,7 @@ export function collectUserMessageBlobPreviewUrls(message: ChatMessage): string[
290296 }
291297 const previewUrls: string[] = [];
292298 for (const attachment of message.attachments) {
293 if (attachment.type !== "image") continue;
299 if (!isImageAttachment(attachment)) continue;
294300 if (!attachment.previewUrl || !attachment.previewUrl.startsWith("blob:")) continue;
295301 previewUrls.push(attachment.previewUrl);
296302 }
apps/web/src/components/ChatView.tsx+3-2
......@@ -126,6 +126,7 @@ import {
126126 DEFAULT_THREAD_TERMINAL_ID,
127127 MAX_TERMINALS_PER_GROUP,
128128 type ChatMessage,
129 isImageAttachment,
129130 type SessionPhase,
130131 type Thread,
131132 type TurnDiffSummary,
......@@ -2560,7 +2561,7 @@ function ChatViewContent(props: ChatViewProps) {
25602561 }
25612562
25622563 const serverPreviewUrls = serverMessage.attachments.flatMap((attachment) =>
2563 attachment.type === "image" && attachment.previewUrl ? [attachment.previewUrl] : [],
2564 isImageAttachment(attachment) && attachment.previewUrl ? [attachment.previewUrl] : [],
25642565 );
25652566 if (
25662567 serverPreviewUrls.length === 0 ||
......@@ -2643,7 +2644,7 @@ function ChatViewContent(props: ChatViewProps) {
26432644 let changed = false;
26442645 let imageIndex = 0;
26452646 const attachments = message.attachments.map((attachment) => {
2646 if (attachment.type !== "image") {
2647 if (!isImageAttachment(attachment)) {
26472648 return attachment;
26482649 }
26492650 const handoffPreviewUrl = handoffPreviewUrls[imageIndex];
apps/web/src/components/chat/MessagesTimeline.tsx+4-6
......@@ -38,7 +38,7 @@ import {
3838 workEntrySignalsSevereFailure,
3939 workLogEntryIsToolLike,
4040} from "../../session-logic";
41import { type TurnDiffSummary } from "../../types";
41import { type ChatImageAttachment, isImageAttachment, type TurnDiffSummary } from "../../types";
4242import {
4343 getRenderablePatch,
4444 resolveDiffThemeName,
......@@ -925,8 +925,6 @@ function TimelineMinimap({
925925// TimelineRowContent — the actual row component
926926// ---------------------------------------------------------------------------
927927
928type TimelineEntry = ReturnType<typeof deriveTimelineEntries>[number];
929type TimelineMessage = Extract<TimelineEntry, { kind: "message" }>["message"];
930928type TimelineWorkEntry = Extract<MessagesTimelineRow, { kind: "work" }>["groupedEntries"][number];
931929type TimelineRow = MessagesTimelineRow;
932930
......@@ -988,7 +986,7 @@ const TimelineRowContent = memo(function TimelineRowContent({ row }: { row: Time
988986
989987function UserTimelineRow({ row }: { row: Extract<TimelineRow, { kind: "message" }> }) {
990988 const ctx = use(TimelineRowCtx);
991 const userImages = row.message.attachments ?? [];
989 const userImages = (row.message.attachments ?? []).filter(isImageAttachment);
992990 const displayedUserMessage = deriveDisplayedUserMessageState(row.message.text);
993991 const terminalContexts = displayedUserMessage.contexts;
994992 const previewAnnotations: ParsedPreviewAnnotation[] = [];
......@@ -1013,7 +1011,7 @@ function UserTimelineRow({ row }: { row: Extract<TimelineRow, { kind: "message"
10131011 <div className="relative max-w-[80%] rounded-2xl bg-message p-3 text-message-foreground">
10141012 {regularImages.length > 0 && (
10151013 <div className="mb-2 grid max-w-[420px] grid-cols-2 gap-2">
1016 {regularImages.map((image: NonNullable<TimelineMessage["attachments"]>[number]) => (
1014 {regularImages.map((image: ChatImageAttachment) => (
10171015 <div
10181016 key={image.id}
10191017 className="overflow-hidden rounded-lg border border-border/80 bg-background/70"
......@@ -1719,7 +1717,7 @@ const UserMessageElementContextChip = memo(function UserMessageElementContextChi
17191717
17201718function UserMessagePreviewAnnotationCard(props: {
17211719 annotation: ParsedPreviewAnnotation;
1722 image: NonNullable<TimelineMessage["attachments"]>[number] | null;
1720 image: ChatImageAttachment | null;
17231721}) {
17241722 const ctx = use(TimelineRowCtx);
17251723 return (
apps/web/src/types.ts+12-1
......@@ -1,4 +1,5 @@
11import type {
2 ChatAttachment as ContractChatAttachment,
23 ChatImageAttachment as ContractChatImageAttachment,
34 OrchestrationCheckpointFile,
45 OrchestrationCheckpointSummary,
......@@ -35,7 +36,17 @@ export interface ChatImageAttachment extends ContractChatImageAttachment {
3536 readonly previewUrl?: string;
3637}
3738
38export type ChatAttachment = ChatImageAttachment;
39// Non-image members pass through with the contract shape. The web UI renders
40// them once it grows file support; until then they only need to typecheck.
41export type ChatAttachment =
42 | ChatImageAttachment
43 | Exclude<ContractChatAttachment, ContractChatImageAttachment>;
44
45// The union has an open member (`type: string`), so a literal comparison does
46// not narrow. Use this guard wherever image-only fields are read.
47export function isImageAttachment(attachment: ChatAttachment): attachment is ChatImageAttachment {
48 return attachment.type === "image";
49}
3950
4051export interface ChatMessage extends Omit<OrchestrationMessage, "attachments"> {
4152 readonly attachments?: ReadonlyArray<ChatAttachment> | undefined;
docs/internals/providers.md+28
......@@ -51,6 +51,34 @@ probes, respect the `enableProviderUpdateChecks` setting, and never fail a provi
5151Codex and Claude drivers apply the classification to every snapshot with `applyModelManifest`;
5252driver kinds absent from the manifest have no legacy concept.
5353
54## Attachment access
55
56The server stores uploaded attachments in its attachment directory, outside the project workspace.
57`ProviderService` adds the absolute path of each attachment to the turn text, then passes every
58attachment to the provider adapter. Each adapter decides what its provider ingests natively:
59
60- Codex, Claude, Cursor, and Grok send images as native image inputs and skip generic files. For
61 these providers, generic files reach the agent only as file paths in the turn text.
62- OpenCode sends PNG/JPEG/GIF/WebP images, text files, and PDFs up to 20 MB as native file parts
63 with their real mime type. Everything else (ZIP and other binaries, image formats model APIs
64 reject, oversized files) falls back to the file path in the turn text, like the other providers.
65
66Claude receives the attachment directory as an allowed additional directory. Codex keeps its
67configured sandbox policy, so access depends on that policy and the selected runtime mode. OpenCode
68allows all paths in full-access mode and requests approval for directories outside the workspace in
69restricted modes. Cursor and Grok use their own provider permission rules.
70
71The server does not copy attachments into a project or bypass provider approval rules. If an agent
72cannot read an attachment, the user must approve the access or select a runtime mode that permits it.
73
74Updated attachment schemas tolerate unknown attachment members, but old image-only clients still
75cannot decode messages that contain file attachments. Client file-picking rollouts must account for
76this limit.
77
78Do not run an old image-only server against state that contains file attachments. Replay decodes
79each persisted event before projection. A file-bearing event can make `ProjectionPipeline` bootstrap
80and `OrchestrationEngine` startup fail for the entire environment, not only the affected thread.
81
5482## How provider work is requested
5583
5684Clients never call a provider directly. They dispatch orchestration commands over the RPC method
packages/contracts/src/assets.test.ts+31-1
......@@ -2,7 +2,10 @@ import * as Schema from "effect/Schema";
22import { describe, expect, it } from "vite-plus/test";
33
44import { AttachmentCreateUploadUrlInput } from "./assets.ts";
5import { PROVIDER_SEND_TURN_MAX_IMAGE_BYTES } from "./orchestration.ts";
5import {
6 PROVIDER_SEND_TURN_MAX_FILE_BYTES,
7 PROVIDER_SEND_TURN_MAX_IMAGE_BYTES,
8} from "./orchestration.ts";
69
710const isUploadInput = Schema.is(AttachmentCreateUploadUrlInput);
811
......@@ -21,10 +24,37 @@ describe("AttachmentCreateUploadUrlInput", () => {
2124 expect(isUploadInput({ ...uploadInput, mimeType: "image/svg+xml" })).toBe(false);
2225 });
2326
27 it("accepts generic files without treating them as provider images", () => {
28 expect(
29 isUploadInput({
30 type: "file",
31 name: "report.pdf",
32 mimeType: "application/pdf",
33 sizeBytes: PROVIDER_SEND_TURN_MAX_IMAGE_BYTES + 1,
34 }),
35 ).toBe(true);
36 expect(
37 isUploadInput({
38 type: "file",
39 name: "diagram.svg",
40 mimeType: "image/svg+xml",
41 sizeBytes: 3,
42 }),
43 ).toBe(true);
44 });
45
2446 it("rejects empty and oversized uploads", () => {
2547 expect(isUploadInput({ ...uploadInput, sizeBytes: 0 })).toBe(false);
2648 expect(
2749 isUploadInput({ ...uploadInput, sizeBytes: PROVIDER_SEND_TURN_MAX_IMAGE_BYTES + 1 }),
2850 ).toBe(false);
51 expect(
52 isUploadInput({
53 type: "file",
54 name: "archive.zip",
55 mimeType: "application/zip",
56 sizeBytes: PROVIDER_SEND_TURN_MAX_FILE_BYTES + 1,
57 }),
58 ).toBe(false);
2959 });
3060});
packages/contracts/src/assets.ts+24-1
......@@ -2,6 +2,7 @@ import * as Schema from "effect/Schema";
22
33import { NonNegativeInt, ThreadId, TrimmedNonEmptyString } from "./baseSchemas.ts";
44import {
5 PROVIDER_SEND_TURN_MAX_FILE_BYTES,
56 PROVIDER_SEND_TURN_MAX_IMAGE_BYTES,
67 PROVIDER_SEND_TURN_SUPPORTED_IMAGE_MIME_TYPES,
78 ProjectFaviconPath,
......@@ -16,6 +17,12 @@ export const AssetResource = Schema.Union([
1617 }),
1718 Schema.TaggedStruct("attachment", {
1819 attachmentId: TrimmedNonEmptyString.check(Schema.isMaxLength(256)),
20 /** Display name and mime from the `ChatAttachment` the caller holds. The
21 server bakes both into the signed URL so downloads carry the real
22 filename and Content-Type. Absent on older clients, which fall back to
23 an octet-stream download without a filename. */
24 fileName: Schema.optionalKey(TrimmedNonEmptyString.check(Schema.isMaxLength(255))),
25 mimeType: Schema.optionalKey(TrimmedNonEmptyString.check(Schema.isMaxLength(100))),
1926 }),
2027 Schema.TaggedStruct("project-favicon", {
2128 cwd: TrimmedNonEmptyString.check(Schema.isMaxLength(ASSET_PATH_MAX_LENGTH)),
......@@ -42,7 +49,8 @@ export type AssetCreateUrlResult = typeof AssetCreateUrlResult.Type;
4249
4350export const ATTACHMENT_UPLOAD_URL_TTL_MS = 10 * 60_000;
4451
45export const AttachmentCreateUploadUrlInput = Schema.Struct({
52const ImageAttachmentCreateUploadUrlInput = Schema.Struct({
53 type: Schema.optionalKey(Schema.Literal("image")),
4654 name: TrimmedNonEmptyString.check(Schema.isMaxLength(255)),
4755 mimeType: Schema.Literals(PROVIDER_SEND_TURN_SUPPORTED_IMAGE_MIME_TYPES),
4856 sizeBytes: NonNegativeInt.check(
......@@ -50,6 +58,21 @@ export const AttachmentCreateUploadUrlInput = Schema.Struct({
5058 Schema.isLessThanOrEqualTo(PROVIDER_SEND_TURN_MAX_IMAGE_BYTES),
5159 ),
5260});
61
62const FileAttachmentCreateUploadUrlInput = Schema.Struct({
63 type: Schema.Literal("file"),
64 name: TrimmedNonEmptyString.check(Schema.isMaxLength(255)),
65 mimeType: TrimmedNonEmptyString.check(Schema.isMaxLength(100)),
66 sizeBytes: NonNegativeInt.check(
67 Schema.isGreaterThanOrEqualTo(1),
68 Schema.isLessThanOrEqualTo(PROVIDER_SEND_TURN_MAX_FILE_BYTES),
69 ),
70});
71
72export const AttachmentCreateUploadUrlInput = Schema.Union([
73 ImageAttachmentCreateUploadUrlInput,
74 FileAttachmentCreateUploadUrlInput,
75]);
5376export type AttachmentCreateUploadUrlInput = typeof AttachmentCreateUploadUrlInput.Type;
5477
5578export const AttachmentCreateUploadUrlResult = Schema.Struct({
packages/contracts/src/environment.test.ts+12
......@@ -39,4 +39,16 @@ describe("ExecutionEnvironmentDescriptor", () => {
3939 }).capabilities.attachmentUploads,
4040 ).toBe(true);
4141 });
42
43 it("preserves the server's generic attachment upload limit", () => {
44 expect(
45 decodeDescriptor({
46 ...descriptor,
47 capabilities: {
48 ...descriptor.capabilities,
49 fileAttachments: { maxUploadBytes: 50 * 1024 * 1024 },
50 },
51 }).capabilities.fileAttachments,
52 ).toEqual({ maxUploadBytes: 50 * 1024 * 1024 });
53 });
4254});
packages/contracts/src/environment.ts+6
......@@ -50,6 +50,12 @@ export const ExecutionEnvironmentCapabilities = Schema.Struct({
5050 connectionProbe: Schema.optionalKey(Schema.Boolean),
5151 /** Missing on older servers, which still accept inline image attachments. */
5252 attachmentUploads: Schema.optionalKey(Schema.Boolean),
53 /** Missing on servers that only accept image attachments. */
54 fileAttachments: Schema.optionalKey(
55 Schema.Struct({
56 maxUploadBytes: Schema.Int.check(Schema.isGreaterThanOrEqualTo(1)),
57 }),
58 ),
5359 /** Server exposes the pull-request list, detail, activity, diff, and mutation APIs. Absent on
5460 servers from before the pull-request workspace shipped, so clients must not probe them. */
5561 pullRequests: Schema.optionalKey(Schema.Boolean),
packages/contracts/src/orchestration.test.ts+91-2
......@@ -1,5 +1,6 @@
11import { assert, it } from "@effect/vitest";
22import * as Effect from "effect/Effect";
3import * as Exit from "effect/Exit";
34import * as Schema from "effect/Schema";
45
56import {
......@@ -20,12 +21,15 @@ import {
2021 OrchestrationThread,
2122 OrchestrationThreadShell,
2223 ProjectCreateCommand,
24 OrchestrationMessage,
25 ThreadMessageSentPayload,
2326 ThreadMetaUpdatedPayload,
2427 ThreadTurnStartCommand,
2528 ThreadCreatedPayload,
2629 ThreadTurnDiff,
2730 ThreadTurnStartRequestedPayload,
2831 isProviderSendTurnSupportedImageMimeType,
32 PROVIDER_SEND_TURN_MAX_FILE_BYTES,
2933} from "./orchestration.ts";
3034import { ProviderInstanceId } from "./providerInstance.ts";
3135
......@@ -37,6 +41,8 @@ const decodeProjectCreatedPayload = Schema.decodeUnknownEffect(ProjectCreatedPay
3741const decodeProjectMetaUpdatedPayload = Schema.decodeUnknownEffect(ProjectMetaUpdatedPayload);
3842const decodeThreadTurnStartCommand = Schema.decodeUnknownEffect(ThreadTurnStartCommand);
3943const decodeClientOrchestrationCommand = Schema.decodeUnknownEffect(ClientOrchestrationCommand);
44const decodeOrchestrationMessage = Schema.decodeUnknownEffect(OrchestrationMessage);
45const decodeThreadMessageSentPayload = Schema.decodeUnknownEffect(ThreadMessageSentPayload);
4046const decodeThreadTurnStartRequestedPayload = Schema.decodeUnknownEffect(
4147 ThreadTurnStartRequestedPayload,
4248);
......@@ -243,7 +249,7 @@ it.effect("decodes thread.turn.start defaults for provider and runtime mode", ()
243249 }),
244250);
245251
246it.effect("accepts both inline and uploaded image attachments from clients", () =>
252it.effect("accepts inline images, uploaded images, and uploaded files from clients", () =>
247253 Effect.gen(function* () {
248254 const command = yield* decodeClientOrchestrationCommand({
249255 type: "thread.turn.start",
......@@ -268,6 +274,13 @@ it.effect("accepts both inline and uploaded image attachments from clients", ()
268274 mimeType: "image/png",
269275 sizeBytes: 3,
270276 },
277 {
278 type: "file",
279 id: "pending-00000000-0000-4000-8000-000000000002-pdf",
280 name: "report.pdf",
281 mimeType: "application/pdf",
282 sizeBytes: 3,
283 },
271284 ],
272285 },
273286 runtimeMode: "full-access",
......@@ -278,9 +291,85 @@ it.effect("accepts both inline and uploaded image attachments from clients", ()
278291 if (command.type !== "thread.turn.start") {
279292 assert.fail(`Expected thread.turn.start, received ${command.type}.`);
280293 }
281 assert.strictEqual(command.message.attachments.length, 2);
294 assert.strictEqual(command.message.attachments.length, 3);
282295 assert.strictEqual("dataUrl" in command.message.attachments[0]!, true);
283296 assert.strictEqual("id" in command.message.attachments[1]!, true);
297 assert.strictEqual(command.message.attachments[2]!.type, "file");
298 }),
299);
300
301// Attachments ride on persisted events and thread streams with no client
302// version negotiation. A type this build does not know must decode instead of
303// failing the whole message.
304it.effect("tolerates attachment types from newer builds when decoding messages", () =>
305 Effect.gen(function* () {
306 const futureAttachment = {
307 type: "somethingnew",
308 id: "thread-1-00000000-0000-4000-8000-000000000003-glb",
309 name: "scene.glb",
310 mimeType: "model/gltf-binary",
311 sizeBytes: 12,
312 };
313
314 const message = yield* decodeOrchestrationMessage({
315 id: "message-1",
316 role: "user",
317 text: "look at this",
318 attachments: [futureAttachment],
319 turnId: null,
320 streaming: false,
321 createdAt: "2026-01-01T00:00:00.000Z",
322 updatedAt: "2026-01-01T00:00:00.000Z",
323 });
324 assert.strictEqual(message.attachments?.length, 1);
325 assert.strictEqual(message.attachments?.[0]!.type, "somethingnew");
326
327 const payload = yield* decodeThreadMessageSentPayload({
328 threadId: "thread-1",
329 messageId: "message-1",
330 role: "user",
331 text: "look at this",
332 attachments: [futureAttachment],
333 turnId: null,
334 streaming: false,
335 createdAt: "2026-01-01T00:00:00.000Z",
336 updatedAt: "2026-01-01T00:00:00.000Z",
337 });
338 assert.strictEqual(payload.attachments?.[0]!.type, "somethingnew");
339 }),
340);
341
342// The tolerant member must not catch malformed known attachments: a file over
343// the size cap or an image with a bad mime has to fail its own schema, not
344// slide through the open one with those constraints unchecked.
345it.effect("rejects malformed known attachment types instead of tolerating them", () =>
346 Effect.gen(function* () {
347 const base = {
348 id: "thread-1-00000000-0000-4000-8000-000000000003-pdf",
349 name: "report.pdf",
350 mimeType: "application/pdf",
351 };
352 const decode = (attachment: unknown) =>
353 decodeOrchestrationMessage({
354 id: "message-1",
355 role: "user",
356 text: "look at this",
357 attachments: [attachment],
358 turnId: null,
359 streaming: false,
360 createdAt: "2026-01-01T00:00:00.000Z",
361 updatedAt: "2026-01-01T00:00:00.000Z",
362 });
363
364 const oversizedFile = yield* Effect.exit(
365 decode({ ...base, type: "file", sizeBytes: PROVIDER_SEND_TURN_MAX_FILE_BYTES + 1 }),
366 );
367 assert.strictEqual(Exit.isFailure(oversizedFile), true);
368
369 const badMimeImage = yield* Effect.exit(
370 decode({ ...base, type: "image", mimeType: "application/pdf", sizeBytes: 12 }),
371 );
372 assert.strictEqual(Exit.isFailure(badMimeImage), true);
284373 }),
285374);
286375
packages/contracts/src/orchestration.ts+40-1
......@@ -156,6 +156,7 @@ export type ProviderUserInputAnswers = typeof ProviderUserInputAnswers.Type;
156156export const PROVIDER_SEND_TURN_MAX_INPUT_CHARS = 120_000;
157157export const PROVIDER_SEND_TURN_MAX_ATTACHMENTS = 8;
158158export const PROVIDER_SEND_TURN_MAX_IMAGE_BYTES = 10 * 1024 * 1024;
159export const PROVIDER_SEND_TURN_MAX_FILE_BYTES = 50 * 1024 * 1024;
159160export const PROVIDER_SEND_TURN_SUPPORTED_IMAGE_MIME_TYPES = [
160161 "image/gif",
161162 "image/jpeg",
......@@ -191,6 +192,40 @@ export const ChatImageAttachment = Schema.Struct({
191192});
192193export type ChatImageAttachment = typeof ChatImageAttachment.Type;
193194
195export const ChatFileAttachment = Schema.Struct({
196 type: Schema.Literal("file"),
197 id: ChatAttachmentId,
198 name: TrimmedNonEmptyString.check(Schema.isMaxLength(255)),
199 mimeType: TrimmedNonEmptyString.check(Schema.isMaxLength(100)),
200 sizeBytes: NonNegativeInt.check(
201 Schema.isGreaterThanOrEqualTo(1),
202 Schema.isLessThanOrEqualTo(PROVIDER_SEND_TURN_MAX_FILE_BYTES),
203 ),
204});
205export type ChatFileAttachment = typeof ChatFileAttachment.Type;
206
207/**
208 * Catch-all for attachment types this build does not know. Attachments ride on
209 * persisted events and thread streams, so a newer server or client must be able
210 * to introduce a type without making older readers fail to decode the whole
211 * message. Decoders keep the shared base fields; consumers skip these or render
212 * them as unsupported. Mirrors how `OrchestrationThreadActivity` keeps `kind`
213 * open. The known discriminators are excluded so a malformed image or file
214 * attachment fails its own schema instead of sliding through here with its
215 * size and mime constraints unchecked.
216 */
217export const ChatUnknownAttachment = Schema.Struct({
218 type: TrimmedNonEmptyString.check(
219 Schema.isMaxLength(50),
220 Schema.isPattern(/^(?!(?:image|file)$)/),
221 ),
222 id: ChatAttachmentId,
223 name: TrimmedNonEmptyString.check(Schema.isMaxLength(255)),
224 mimeType: TrimmedNonEmptyString.check(Schema.isMaxLength(100)),
225 sizeBytes: NonNegativeInt,
226});
227export type ChatUnknownAttachment = typeof ChatUnknownAttachment.Type;
228
194229const UploadChatImageAttachment = Schema.Struct({
195230 type: Schema.Literal("image"),
196231 name: TrimmedNonEmptyString.check(Schema.isMaxLength(255)),
......@@ -202,7 +237,11 @@ const UploadChatImageAttachment = Schema.Struct({
202237});
203238export type UploadChatImageAttachment = typeof UploadChatImageAttachment.Type;
204239
205export const ChatAttachment = Schema.Union([ChatImageAttachment]);
240export const ChatAttachment = Schema.Union([
241 ChatImageAttachment,
242 ChatFileAttachment,
243 ChatUnknownAttachment,
244]);
206245export type ChatAttachment = typeof ChatAttachment.Type;
207246const UploadChatAttachment = Schema.Union([UploadChatImageAttachment]);
208247export type UploadChatAttachment = typeof UploadChatAttachment.Type;