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(...@@ -1040,7 +1040,9 @@ function renderFeedEntry(
1040 const isUser = message.role === "user";1040 const isUser = message.role === "user";
1041 const styles = isUser ? markdownStyles.user : markdownStyles.assistant;1041 const styles = isUser ? markdownStyles.user : markdownStyles.assistant;
1042 const timestampLabel = formatMessageTime(isUser ? message.createdAt : message.updatedAt);1042 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 );
1044 const hasReviewCommentContext = message.text.includes("<review_comment");1046 const hasReviewCommentContext = message.text.includes("<review_comment");
1045 // A bubble that sizes itself from its content cannot lay out a block whose1047 // A bubble that sizes itself from its content cannot lay out a block whose
1046 // intrinsic width overflows `maxWidth`: Android positions the bubble's1048 // intrinsic width overflows `maxWidth`: Android positions the bubble's
apps/server/src/assets/AssetAccess.ts+30-4
...@@ -37,7 +37,7 @@ import {...@@ -37,7 +37,7 @@ import {
37 timingSafeEqualBase64Url,37 timingSafeEqualBase64Url,
38} from "../auth/utils.ts";38} from "../auth/utils.ts";
39import * as ServerSecretStore from "../auth/ServerSecretStore.ts";39import * as ServerSecretStore from "../auth/ServerSecretStore.ts";
40import { resolveAttachmentPathById } from "../attachmentStore.ts";40import { parseAttachmentFileExtension, resolveAttachmentPathById } from "../attachmentStore.ts";
41import * as ServerConfig from "../config.ts";41import * as ServerConfig from "../config.ts";
42import * as ProjectFaviconResolver from "../project/ProjectFaviconResolver.ts";42import * as ProjectFaviconResolver from "../project/ProjectFaviconResolver.ts";
43import * as WorkspacePaths from "../workspace/WorkspacePaths.ts";43import * as WorkspacePaths from "../workspace/WorkspacePaths.ts";
...@@ -79,6 +79,13 @@ const AssetClaimsSchema = Schema.Union([...@@ -79,6 +79,13 @@ const AssetClaimsSchema = Schema.Union([
79 version: Schema.Literal(1),79 version: Schema.Literal(1),
80 kind: Schema.Literal("attachment"),80 kind: Schema.Literal("attachment"),
81 attachmentId: Schema.String,81 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),
82 expiresAt: Schema.Number,89 expiresAt: Schema.Number,
83 }),90 }),
84 Schema.Struct({91 Schema.Struct({
...@@ -101,7 +108,13 @@ const AssetClaimsJson = Schema.fromJsonString(AssetClaimsSchema);...@@ -101,7 +108,13 @@ const AssetClaimsJson = Schema.fromJsonString(AssetClaimsSchema);
101const decodeAssetClaims = Schema.decodeUnknownOption(AssetClaimsJson);108const decodeAssetClaims = Schema.decodeUnknownOption(AssetClaimsJson);
102const encodeAssetClaims = Schema.encodeSync(AssetClaimsJson);109const 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
106function decodeClaims(encodedPayload: string): AssetClaims | null {119function decodeClaims(encodedPayload: string): AssetClaims | null {
107 try {120 try {
...@@ -286,13 +299,20 @@ export const issueAssetUrl = Effect.fn("AssetAccess.issueAssetUrl")(function* (i...@@ -286,13 +299,20 @@ export const issueAssetUrl = Effect.fn("AssetAccess.issueAssetUrl")(function* (i
286 resource: input.resource,299 resource: input.resource,
287 });300 });
288 }301 }
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;
289 claims = {306 claims = {
290 version: 1,307 version: 1,
291 kind: "attachment",308 kind: "attachment",
292 attachmentId: input.resource.attachmentId,309 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 } : {}),
293 expiresAt,313 expiresAt,
294 };314 };
295 fileName = path.basename(attachmentPath);315 fileName = input.resource.fileName ?? path.basename(attachmentPath);
296 break;316 break;
297 }317 }
298 case "project-favicon": {318 case "project-favicon": {
...@@ -464,7 +484,13 @@ export const resolveAsset = Effect.fn("AssetAccess.resolveAsset")(function* (...@@ -464,7 +484,13 @@ export const resolveAsset = Effect.fn("AssetAccess.resolveAsset")(function* (
464 Effect.orElseSucceed(() => Option.none()),484 Effect.orElseSucceed(() => Option.none()),
465 );485 );
466 return Option.isSome(info) && info.value.type === "File"486 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)
468 : null;494 : null;
469 }495 }
470496
apps/server/src/assets/AttachmentUpload.test.ts+122
...@@ -4,11 +4,16 @@ import * as NodePath from "node:path";...@@ -4,11 +4,16 @@ import * as NodePath from "node:path";
44
5import * as NodeServices from "@effect/platform-node/NodeServices";5import * as NodeServices from "@effect/platform-node/NodeServices";
6import { describe, expect, it } from "@effect/vitest";6import { describe, expect, it } from "@effect/vitest";
7import * as Deferred from "effect/Deferred";
7import * as Effect from "effect/Effect";8import * as Effect from "effect/Effect";
9import * as Fiber from "effect/Fiber";
8import * as Layer from "effect/Layer";10import * as Layer from "effect/Layer";
11import * as Schema from "effect/Schema";
12import * as Stream from "effect/Stream";
9import * as TestClock from "effect/testing/TestClock";13import * as TestClock from "effect/testing/TestClock";
1014
11import * as ServerSecretStore from "../auth/ServerSecretStore.ts";15import * as ServerSecretStore from "../auth/ServerSecretStore.ts";
16import { base64UrlEncode, signPayload } from "../auth/utils.ts";
12import * as ServerConfig from "../config.ts";17import * as ServerConfig from "../config.ts";
13import { parseThreadSegmentFromAttachmentId } from "../attachmentStore.ts";18import { parseThreadSegmentFromAttachmentId } from "../attachmentStore.ts";
14import {19import {
...@@ -30,6 +35,19 @@ const uploadInput = {...@@ -30,6 +35,19 @@ const uploadInput = {
30 sizeBytes: 6,35 sizeBytes: 6,
31} as const;36} 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
33describe("AttachmentUpload", () => {51describe("AttachmentUpload", () => {
34 it.effect("signs the attachment metadata and validates the upload token", () =>52 it.effect("signs the attachment metadata and validates the upload token", () =>
35 Effect.gen(function* () {53 Effect.gen(function* () {
...@@ -59,6 +77,31 @@ describe("AttachmentUpload", () => {...@@ -59,6 +77,31 @@ describe("AttachmentUpload", () => {
59 }).pipe(Effect.provide(testLayer)),77 }).pipe(Effect.provide(testLayer)),
60 );78 );
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
62 it.effect("rejects expired upload tokens", () =>105 it.effect("rejects expired upload tokens", () =>
63 Effect.gen(function* () {106 Effect.gen(function* () {
64 const issued = yield* issueAttachmentUploadUrl(uploadInput);107 const issued = yield* issueAttachmentUploadUrl(uploadInput);
...@@ -108,6 +151,85 @@ describe("AttachmentUpload", () => {...@@ -108,6 +151,85 @@ describe("AttachmentUpload", () => {
108 }).pipe(Effect.provide(testLayer)),151 }).pipe(Effect.provide(testLayer)),
109 );152 );
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
111 it.effect("deletes pending uploads without deleting thread-owned copies", () =>233 it.effect("deletes pending uploads without deleting thread-owned copies", () =>
112 Effect.gen(function* () {234 Effect.gen(function* () {
113 const config = yield* ServerConfig.ServerConfig;235 const config = yield* ServerConfig.ServerConfig;
apps/server/src/assets/AttachmentUpload.ts+43-14
...@@ -12,8 +12,11 @@ import * as FileSystem from "effect/FileSystem";...@@ -12,8 +12,11 @@ import * as FileSystem from "effect/FileSystem";
12import * as Option from "effect/Option";12import * as Option from "effect/Option";
13import * as Path from "effect/Path";13import * as Path from "effect/Path";
14import * as Schema from "effect/Schema";14import * as Schema from "effect/Schema";
15import * as Stream from "effect/Stream";
16import type * as HttpServerRequest from "effect/unstable/http/HttpServerRequest";
1517
16import {18import {
19 attachmentFileExtension,
17 createPendingAttachmentId,20 createPendingAttachmentId,
18 parseThreadSegmentFromAttachmentId,21 parseThreadSegmentFromAttachmentId,
19 PENDING_ATTACHMENT_THREAD_SEGMENT,22 PENDING_ATTACHMENT_THREAD_SEGMENT,
...@@ -41,6 +44,9 @@ const lastPendingSweepByDirectory = new Map<string, number>();...@@ -41,6 +44,9 @@ const lastPendingSweepByDirectory = new Map<string, number>();
41const AttachmentUploadClaims = Schema.Struct({44const AttachmentUploadClaims = Schema.Struct({
42 version: Schema.Literal(1),45 version: Schema.Literal(1),
43 kind: Schema.Literal("attachment-upload"),46 kind: Schema.Literal("attachment-upload"),
47 type: Schema.Literals(["image", "file"]).pipe(
48 Schema.withDecodingDefault(Effect.succeed("image" as const)),
49 ),
44 attachmentId: Schema.String,50 attachmentId: Schema.String,
45 name: Schema.String,51 name: Schema.String,
46 mimeType: Schema.String,52 mimeType: Schema.String,
...@@ -89,12 +95,16 @@ export const issueAttachmentUploadUrl = Effect.fn("AttachmentUpload.issueUrl")(f...@@ -89,12 +95,16 @@ export const issueAttachmentUploadUrl = Effect.fn("AttachmentUpload.issueUrl")(f
89 }95 }
90 }96 }
9197
92 const attachmentId = createPendingAttachmentId();98 const attachmentType = input.type ?? "image";
99 const attachmentId = createPendingAttachmentId(
100 attachmentType === "file" ? attachmentFileExtension(input.name) : undefined,
101 );
93 const expiresAt = nowMs + ATTACHMENT_UPLOAD_URL_TTL_MS;102 const expiresAt = nowMs + ATTACHMENT_UPLOAD_URL_TTL_MS;
94 const encodedPayload = base64UrlEncode(103 const encodedPayload = base64UrlEncode(
95 encodeAttachmentUploadClaims({104 encodeAttachmentUploadClaims({
96 version: 1,105 version: 1,
97 kind: "attachment-upload",106 kind: "attachment-upload",
107 type: attachmentType,
98 attachmentId,108 attachmentId,
99 name: input.name,109 name: input.name,
100 mimeType: input.mimeType,110 mimeType: input.mimeType,
...@@ -141,18 +151,21 @@ export type StoreAttachmentUploadResult =...@@ -141,18 +151,21 @@ export type StoreAttachmentUploadResult =
141151
142export const storeAttachmentUpload = Effect.fn("AttachmentUpload.store")(function* (152export const storeAttachmentUpload = Effect.fn("AttachmentUpload.store")(function* (
143 claims: AttachmentUploadClaims,153 claims: AttachmentUploadClaims,
144 bytes: Uint8Array,154 body: Uint8Array | HttpServerRequest.HttpServerRequest["stream"],
145) {155) {
146 if (bytes.byteLength !== claims.sizeBytes) {156 if (body instanceof Uint8Array && body.byteLength !== claims.sizeBytes) {
147 return {157 return {
148 ok: false,158 ok: false,
149 status: 400,159 status: 400,
150 detail: `Body was ${bytes.byteLength} bytes, expected ${claims.sizeBytes}.`,160 detail: `Body was ${body.byteLength} bytes, expected ${claims.sizeBytes}.`,
151 } satisfies StoreAttachmentUploadResult;161 } satisfies StoreAttachmentUploadResult;
152 }162 }
153163
154 const config = yield* ServerConfig.ServerConfig;164 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 });
156 const relativePath = `${claims.attachmentId}${extension}`;169 const relativePath = `${claims.attachmentId}${extension}`;
157 const finalPath = resolveAttachmentRelativePath({170 const finalPath = resolveAttachmentRelativePath({
158 attachmentsDir: config.attachmentsDir,171 attachmentsDir: config.attachmentsDir,
...@@ -168,21 +181,34 @@ export const storeAttachmentUpload = Effect.fn("AttachmentUpload.store")(functio...@@ -168,21 +181,34 @@ export const storeAttachmentUpload = Effect.fn("AttachmentUpload.store")(functio
168181
169 const fileSystem = yield* FileSystem.FileSystem;182 const fileSystem = yield* FileSystem.FileSystem;
170 const path = yield* Path.Path;183 const path = yield* Path.Path;
184 let receivedBytes = 0;
185 const bodyStream = body instanceof Uint8Array ? Stream.make(body) : body;
171 return yield* Effect.gen(function* () {186 return yield* Effect.gen(function* () {
172 yield* fileSystem.makeDirectory(path.dirname(finalPath), { recursive: true });187 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 }
174 yield* fileSystem.rename(partPath, finalPath);204 yield* fileSystem.rename(partPath, finalPath);
175 return { ok: true } satisfies StoreAttachmentUploadResult;205 return { ok: true } satisfies StoreAttachmentUploadResult;
176 }).pipe(206 }).pipe(
177 Effect.catch((cause) =>207 Effect.catch((cause) =>
178 fileSystem.remove(partPath, { force: true }).pipe(208 Effect.logError("Failed to persist attachment upload.", {
179 Effect.orElseSucceed(() => undefined),209 attachmentId: claims.attachmentId,
180 Effect.andThen(210 cause,
181 Effect.logError("Failed to persist attachment upload.", {211 }).pipe(
182 attachmentId: claims.attachmentId,
183 cause,
184 }),
185 ),
186 Effect.as({212 Effect.as({
187 ok: false,213 ok: false,
188 status: 500,214 status: 500,
...@@ -190,6 +216,9 @@ export const storeAttachmentUpload = Effect.fn("AttachmentUpload.store")(functio...@@ -190,6 +216,9 @@ export const storeAttachmentUpload = Effect.fn("AttachmentUpload.store")(functio
190 } satisfies StoreAttachmentUploadResult),216 } satisfies StoreAttachmentUploadResult),
191 ),217 ),
192 ),218 ),
219 Effect.ensuring(
220 fileSystem.remove(partPath, { force: true }).pipe(Effect.orElseSucceed(() => undefined)),
221 ),
193 );222 );
194});223});
195224
apps/server/src/attachmentStore.test.ts+36-2
...@@ -6,9 +6,11 @@ import * as NodePath from "node:path";...@@ -6,9 +6,11 @@ import * as NodePath from "node:path";
6import { describe, expect, it } from "vite-plus/test";6import { describe, expect, it } from "vite-plus/test";
77
8import {8import {
9 attachmentFileExtension,
9 createAttachmentId,10 createAttachmentId,
10 createPendingAttachmentId,11 createPendingAttachmentId,
11 parseAttachmentUuid,12 parseAttachmentUuid,
13 parseAttachmentFileExtension,
12 planAttachmentClaim,14 planAttachmentClaim,
13 parseThreadSegmentFromAttachmentId,15 parseThreadSegmentFromAttachmentId,
14 resolveAttachmentPathById,16 resolveAttachmentPathById,
...@@ -58,6 +60,21 @@ describe("attachmentStore", () => {...@@ -58,6 +60,21 @@ describe("attachmentStore", () => {
58 );60 );
59 });61 });
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
61 it("resolves attachment path by id using the extension that exists on disk", () => {78 it("resolves attachment path by id using the extension that exists on disk", () => {
62 const attachmentsDir = NodeFS.mkdtempSync(79 const attachmentsDir = NodeFS.mkdtempSync(
63 NodePath.join(NodeOS.tmpdir(), "t3code-attachment-store-"),80 NodePath.join(NodeOS.tmpdir(), "t3code-attachment-store-"),
...@@ -92,6 +109,21 @@ describe("attachmentStore", () => {...@@ -92,6 +109,21 @@ describe("attachmentStore", () => {
92 }109 }
93 });110 });
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
95 it("plans pending attachment claims with direct filename lookups", () => {127 it("plans pending attachment claims with direct filename lookups", () => {
96 const attachmentsDir = NodeFS.mkdtempSync(128 const attachmentsDir = NodeFS.mkdtempSync(
97 NodePath.join(NodeOS.tmpdir(), "t3code-attachment-claim-"),129 NodePath.join(NodeOS.tmpdir(), "t3code-attachment-claim-"),
...@@ -147,15 +179,17 @@ describe("attachmentStore", () => {...@@ -147,15 +179,17 @@ describe("attachmentStore", () => {
147 const oldTimeSeconds = (now - 2 * 24 * 60 * 60 * 1000) / 1000;179 const oldTimeSeconds = (now - 2 * 24 * 60 * 60 * 1000) / 1000;
148 const uuid = "00000000-0000-4000-8000-000000000002";180 const uuid = "00000000-0000-4000-8000-000000000002";
149 const pendingPath = NodePath.join(attachmentsDir, `pending-${uuid}.png`);181 const pendingPath = NodePath.join(attachmentsDir, `pending-${uuid}.png`);
182 const pendingFilePath = NodePath.join(attachmentsDir, `pending-${uuid}-pdf.pdf`);
150 const threadPath = NodePath.join(attachmentsDir, `thread-1-${uuid}.png`);183 const threadPath = NodePath.join(attachmentsDir, `thread-1-${uuid}.png`);
151 const partialPath = NodePath.join(attachmentsDir, `${uuid}.part`);184 const partialPath = NodePath.join(attachmentsDir, `${uuid}.part`);
152 for (const filePath of [pendingPath, threadPath, partialPath]) {185 for (const filePath of [pendingPath, pendingFilePath, threadPath, partialPath]) {
153 NodeFS.writeFileSync(filePath, Buffer.from("pixels"));186 NodeFS.writeFileSync(filePath, Buffer.from("pixels"));
154 NodeFS.utimesSync(filePath, oldTimeSeconds, oldTimeSeconds);187 NodeFS.utimesSync(filePath, oldTimeSeconds, oldTimeSeconds);
155 }188 }
156189
157 expect(sweepStalePendingAttachments({ attachmentsDir, nowMs: now })).toEqual({ deleted: 2 });190 expect(sweepStalePendingAttachments({ attachmentsDir, nowMs: now })).toEqual({ deleted: 3 });
158 expect(NodeFS.existsSync(pendingPath)).toBe(false);191 expect(NodeFS.existsSync(pendingPath)).toBe(false);
192 expect(NodeFS.existsSync(pendingFilePath)).toBe(false);
159 expect(NodeFS.existsSync(partialPath)).toBe(false);193 expect(NodeFS.existsSync(partialPath)).toBe(false);
160 expect(NodeFS.existsSync(threadPath)).toBe(true);194 expect(NodeFS.existsSync(threadPath)).toBe(true);
161 } finally {195 } finally {
apps/server/src/attachmentStore.ts+55-8
...@@ -15,8 +15,9 @@ const ATTACHMENT_FILENAME_EXTENSIONS = [...SAFE_IMAGE_FILE_EXTENSIONS, ".bin"];...@@ -15,8 +15,9 @@ const ATTACHMENT_FILENAME_EXTENSIONS = [...SAFE_IMAGE_FILE_EXTENSIONS, ".bin"];
15const ATTACHMENT_ID_THREAD_SEGMENT_MAX_CHARS = 80;15const ATTACHMENT_ID_THREAD_SEGMENT_MAX_CHARS = 80;
16const ATTACHMENT_ID_THREAD_SEGMENT_PATTERN = "[a-z0-9_]+(?:-[a-z0-9_]+)*";16const ATTACHMENT_ID_THREAD_SEGMENT_PATTERN = "[a-z0-9_]+(?:-[a-z0-9_]+)*";
17const ATTACHMENT_ID_UUID_PATTERN = "[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}";17const 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}";
18const ATTACHMENT_ID_PATTERN = new RegExp(19const 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}))?$`,
20 "i",21 "i",
21);22);
2223
...@@ -39,8 +40,28 @@ export function toSafeThreadAttachmentSegment(threadId: string): string | null {...@@ -39,8 +40,28 @@ export function toSafeThreadAttachmentSegment(threadId: string): string | null {
39 return segment === PENDING_ATTACHMENT_THREAD_SEGMENT ? "_pending" : segment;40 return segment === PENDING_ATTACHMENT_THREAD_SEGMENT ? "_pending" : segment;
40}41}
4142
42export function createPendingAttachmentId(): string {43export function attachmentFileExtension(fileName: string): string {
43 return `${PENDING_ATTACHMENT_THREAD_SEGMENT}-${NodeCrypto.randomUUID()}`;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)}`;
44}65}
4566
46export function parseAttachmentUuid(attachmentId: string): string | null {67export function parseAttachmentUuid(attachmentId: string): string | null {
...@@ -51,12 +72,20 @@ export function parseAttachmentUuid(attachmentId: string): string | null {...@@ -51,12 +72,20 @@ export function parseAttachmentUuid(attachmentId: string): string | null {
51 return normalizedId.match(ATTACHMENT_ID_PATTERN)?.[2]?.toLowerCase() ?? null;72 return normalizedId.match(ATTACHMENT_ID_PATTERN)?.[2]?.toLowerCase() ?? null;
52}73}
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 {
55 const threadSegment = toSafeThreadAttachmentSegment(threadId);84 const threadSegment = toSafeThreadAttachmentSegment(threadId);
56 if (!threadSegment) {85 if (!threadSegment) {
57 return null;86 return null;
58 }87 }
59 return `${threadSegment}-${NodeCrypto.randomUUID()}`;88 return `${threadSegment}-${NodeCrypto.randomUUID()}${attachmentIdExtensionSuffix(extension)}`;
60}89}
6190
62export function parseThreadSegmentFromAttachmentId(attachmentId: string): string | null {91export function parseThreadSegmentFromAttachmentId(attachmentId: string): string | null {
...@@ -71,7 +100,8 @@ export function parseThreadSegmentFromAttachmentId(attachmentId: string): string...@@ -71,7 +100,8 @@ export function parseThreadSegmentFromAttachmentId(attachmentId: string): string
71 return match[1]?.toLowerCase() ?? null;100 return match[1]?.toLowerCase() ?? null;
72}101}
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 {
75 switch (attachment.type) {105 switch (attachment.type) {
76 case "image": {106 case "image": {
77 const extension = inferImageExtension({107 const extension = inferImageExtension({
...@@ -80,6 +110,10 @@ export function attachmentRelativePath(attachment: ChatAttachment): string {...@@ -80,6 +110,10 @@ export function attachmentRelativePath(attachment: ChatAttachment): string {
80 });110 });
81 return `${attachment.id}${extension}`;111 return `${attachment.id}${extension}`;
82 }112 }
113 case "file":
114 return `${attachment.id}${attachmentFileExtension(attachment.name)}`;
115 default:
116 return null;
83 }117 }
84}118}
85119
...@@ -87,9 +121,13 @@ export function resolveAttachmentPath(input: {...@@ -87,9 +121,13 @@ export function resolveAttachmentPath(input: {
87 readonly attachmentsDir: string;121 readonly attachmentsDir: string;
88 readonly attachment: ChatAttachment;122 readonly attachment: ChatAttachment;
89}): string | null {123}): string | null {
124 const relativePath = attachmentRelativePath(input.attachment);
125 if (!relativePath) {
126 return null;
127 }
90 return resolveAttachmentRelativePath({128 return resolveAttachmentRelativePath({
91 attachmentsDir: input.attachmentsDir,129 attachmentsDir: input.attachmentsDir,
92 relativePath: attachmentRelativePath(input.attachment),130 relativePath,
93 });131 });
94}132}
95133
...@@ -101,6 +139,14 @@ export function resolveAttachmentPathById(input: {...@@ -101,6 +139,14 @@ export function resolveAttachmentPathById(input: {
101 if (!normalizedId || normalizedId.includes("/") || normalizedId.includes(".")) {139 if (!normalizedId || normalizedId.includes("/") || normalizedId.includes(".")) {
102 return null;140 return null;
103 }141 }
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 }
104 for (const extension of ATTACHMENT_FILENAME_EXTENSIONS) {150 for (const extension of ATTACHMENT_FILENAME_EXTENSIONS) {
105 const maybePath = resolveAttachmentRelativePath({151 const maybePath = resolveAttachmentRelativePath({
106 attachmentsDir: input.attachmentsDir,152 attachmentsDir: input.attachmentsDir,
...@@ -147,7 +193,8 @@ export function planAttachmentClaim(input: {...@@ -147,7 +193,8 @@ export function planAttachmentClaim(input: {
147 if (!currentPath) {193 if (!currentPath) {
148 return { ok: false, reason: "attachment not found (removed or expired)" };194 return { ok: false, reason: "attachment not found (removed or expired)" };
149 }195 }
150 const finalId = createAttachmentId(input.threadId);196 const fileExtension = parseAttachmentFileExtension(input.attachmentId) ?? undefined;
197 const finalId = createAttachmentId(input.threadId, fileExtension);
151 if (!finalId) {198 if (!finalId) {
152 return { ok: false, reason: "failed to create attachment id" };199 return { ok: false, reason: "failed to create attachment id" };
153 }200 }
apps/server/src/environment/ServerEnvironment.test.ts+1
...@@ -91,6 +91,7 @@ it.layer(NodeServices.layer)("ServerEnvironmentLive", (it) => {...@@ -91,6 +91,7 @@ it.layer(NodeServices.layer)("ServerEnvironmentLive", (it) => {
91 expect(second.capabilities.repositoryIdentity).toBe(true);91 expect(second.capabilities.repositoryIdentity).toBe(true);
92 expect(second.capabilities.connectionProbe).toBe(true);92 expect(second.capabilities.connectionProbe).toBe(true);
93 expect(second.capabilities.attachmentUploads).toBe(true);93 expect(second.capabilities.attachmentUploads).toBe(true);
94 expect(second.capabilities.fileAttachments).toEqual({ maxUploadBytes: 50 * 1024 * 1024 });
94 expect(second.capabilities.pullRequests).toBe(true);95 expect(second.capabilities.pullRequests).toBe(true);
95 expect(second.capabilities.threadTitleRegeneration).toBe(true);96 expect(second.capabilities.threadTitleRegeneration).toBe(true);
96 expect(second.capabilities.threadPullRequestLinking).toBe(true);97 expect(second.capabilities.threadPullRequestLinking).toBe(true);
apps/server/src/environment/ServerEnvironment.ts+6-1
...@@ -1,4 +1,8 @@...@@ -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";
2import { HostProcessArchitecture, HostProcessPlatform } from "@t3tools/shared/hostProcess";6import { HostProcessArchitecture, HostProcessPlatform } from "@t3tools/shared/hostProcess";
3import * as Context from "effect/Context";7import * as Context from "effect/Context";
4import * as Crypto from "effect/Crypto";8import * as Crypto from "effect/Crypto";
...@@ -147,6 +151,7 @@ export const make = Effect.gen(function* () {...@@ -147,6 +151,7 @@ export const make = Effect.gen(function* () {
147 repositoryIdentity: true,151 repositoryIdentity: true,
148 connectionProbe: true,152 connectionProbe: true,
149 attachmentUploads: true,153 attachmentUploads: true,
154 fileAttachments: { maxUploadBytes: PROVIDER_SEND_TURN_MAX_FILE_BYTES },
150 pullRequests: true,155 pullRequests: true,
151 threadSettlement: true,156 threadSettlement: true,
152 threadSnooze: true,157 threadSnooze: true,
apps/server/src/http.test.ts+81-1
...@@ -1,7 +1,12 @@...@@ -1,7 +1,12 @@
1import { expect, it } from "@effect/vitest";1import { expect, it } from "@effect/vitest";
2import { describe } from "vite-plus/test";2import { 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
6describe("http dev routing", () => {11describe("http dev routing", () => {
7 it("treats localhost and loopback addresses as local", () => {12 it("treats localhost and loopback addresses as local", () => {
...@@ -55,4 +60,79 @@ describe("assetResponseHeaders", () => {...@@ -55,4 +60,79 @@ describe("assetResponseHeaders", () => {
55 "text/html; charset=utf-8",60 "text/html; charset=utf-8",
56 );61 );
57 });62 });
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 });
58});138});
apps/server/src/http.ts+61-15
...@@ -12,6 +12,7 @@ import * as FileSystem from "effect/FileSystem";...@@ -12,6 +12,7 @@ import * as FileSystem from "effect/FileSystem";
12import * as Layer from "effect/Layer";12import * as Layer from "effect/Layer";
13import * as Option from "effect/Option";13import * as Option from "effect/Option";
14import * as Path from "effect/Path";14import * as Path from "effect/Path";
15import * as Stream from "effect/Stream";
15import { cast } from "effect/Function";16import { cast } from "effect/Function";
16import {17import {
17 HttpBody,18 HttpBody,
...@@ -50,15 +51,57 @@ const LOOPBACK_HOSTNAMES = new Set(["127.0.0.1", "::1", "localhost"]);...@@ -50,15 +51,57 @@ const LOOPBACK_HOSTNAMES = new Set(["127.0.0.1", "::1", "localhost"]);
50const DESKTOP_RENDERER_ORIGINS = ["t3code://app", "t3code-dev://app"];51const DESKTOP_RENDERER_ORIGINS = ["t3code://app", "t3code-dev://app"];
51const SVG_CONTENT_SECURITY_POLICY = "default-src 'none'; style-src 'unsafe-inline'; sandbox";52const 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> {
54 const lowerPath = filePath.toLowerCase();88 const lowerPath = filePath.toLowerCase();
55 return {89 return {
56 "Cache-Control": "private, max-age=3600",90 "Cache-Control": "private, max-age=3600",
57 "X-Content-Type-Options": "nosniff",91 "X-Content-Type-Options": "nosniff",
58 ...(lowerPath.endsWith(".html") || lowerPath.endsWith(".htm")92 ...(options?.download
59 ? { "Content-Type": "text/html; charset=utf-8" }93 ? {
60 : {}),94 "Content-Disposition": downloadContentDisposition(options.fileName),
61 ...(lowerPath.endsWith(".svg")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")
62 ? { "Content-Security-Policy": SVG_CONTENT_SECURITY_POLICY }105 ? { "Content-Security-Policy": SVG_CONTENT_SECURITY_POLICY }
63 : {}),106 : {}),
64 };107 };
...@@ -228,7 +271,16 @@ export const assetRouteLayer = HttpRouter.add(...@@ -228,7 +271,16 @@ export const assetRouteLayer = HttpRouter.add(
228 }271 }
229 return yield* HttpServerResponse.file(asset.path, {272 return yield* HttpServerResponse.file(asset.path, {
230 status: 200,273 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 ),
232 }).pipe(284 }).pipe(
233 Effect.orElseSucceed(() => HttpServerResponse.text("Internal Server Error", { status: 500 })),285 Effect.orElseSucceed(() => HttpServerResponse.text("Internal Server Error", { status: 500 })),
234 );286 );
...@@ -265,15 +317,9 @@ export const attachmentUploadRouteLayer = HttpRouter.add(...@@ -265,15 +317,9 @@ export const attachmentUploadRouteLayer = HttpRouter.add(
265 });317 });
266 }318 }
267319
268 const body = yield* request.arrayBuffer.pipe(320 // Keep the request stream in the route scope until the response is sent.
269 Effect.provideService(HttpServerRequest.MaxBodySize, FileSystem.Size(claims.sizeBytes)),321 const bodyPull = yield* Stream.toPull(request.stream);
270 Effect.orElseSucceed(() => null),322 const stored = yield* storeAttachmentUpload(claims, Stream.fromPull(Effect.succeed(bodyPull)));
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));
277 return stored.ok323 return stored.ok
278 ? HttpServerResponse.empty({ status: 204 })324 ? HttpServerResponse.empty({ status: 204 })
279 : HttpServerResponse.text(stored.detail, { status: stored.status });325 : HttpServerResponse.text(stored.detail, { status: stored.status });
apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts+23
...@@ -882,6 +882,7 @@ it.layer(...@@ -882,6 +882,7 @@ it.layer(
882 const now = "2026-01-01T00:00:00.000Z";882 const now = "2026-01-01T00:00:00.000Z";
883 const threadId = ThreadId.make("Thread Revert.Files");883 const threadId = ThreadId.make("Thread Revert.Files");
884 const keepAttachmentId = "thread-revert-files-00000000-0000-4000-8000-000000000001";884 const keepAttachmentId = "thread-revert-files-00000000-0000-4000-8000-000000000001";
885 const keepFileAttachmentId = "thread-revert-files-00000000-0000-4000-8000-000000000004-pdf";
885 const removeAttachmentId = "thread-revert-files-00000000-0000-4000-8000-000000000002";886 const removeAttachmentId = "thread-revert-files-00000000-0000-4000-8000-000000000002";
886 const otherThreadAttachmentId =887 const otherThreadAttachmentId =
887 "thread-revert-files-extra-00000000-0000-4000-8000-000000000003";888 "thread-revert-files-extra-00000000-0000-4000-8000-000000000003";
...@@ -983,6 +984,13 @@ it.layer(...@@ -983,6 +984,13 @@ it.layer(
983 mimeType: "image/png",984 mimeType: "image/png",
984 sizeBytes: 5,985 sizeBytes: 5,
985 },986 },
987 {
988 type: "file",
989 id: keepFileAttachmentId,
990 name: "keep.pdf",
991 mimeType: "application/pdf",
992 sizeBytes: 5,
993 },
986 ],994 ],
987 turnId: TurnId.make("turn-keep"),995 turnId: TurnId.make("turn-keep"),
988 streaming: false,996 streaming: false,
...@@ -1045,9 +1053,11 @@ it.layer(...@@ -1045,9 +1053,11 @@ it.layer(
1045 });1053 });
10461054
1047 const keepPath = path.join(attachmentsDir, `${keepAttachmentId}.png`);1055 const keepPath = path.join(attachmentsDir, `${keepAttachmentId}.png`);
1056 const keepFilePath = path.join(attachmentsDir, `${keepFileAttachmentId}.pdf`);
1048 const removePath = path.join(attachmentsDir, `${removeAttachmentId}.png`);1057 const removePath = path.join(attachmentsDir, `${removeAttachmentId}.png`);
1049 yield* fileSystem.makeDirectory(attachmentsDir, { recursive: true });1058 yield* fileSystem.makeDirectory(attachmentsDir, { recursive: true });
1050 yield* fileSystem.writeFileString(keepPath, "keep");1059 yield* fileSystem.writeFileString(keepPath, "keep");
1060 yield* fileSystem.writeFileString(keepFilePath, "keep");
1051 yield* fileSystem.writeFileString(removePath, "remove");1061 yield* fileSystem.writeFileString(removePath, "remove");
1052 const otherThreadPath = path.join(attachmentsDir, `${otherThreadAttachmentId}.png`);1062 const otherThreadPath = path.join(attachmentsDir, `${otherThreadAttachmentId}.png`);
1053 yield* fileSystem.writeFileString(otherThreadPath, "other");1063 yield* fileSystem.writeFileString(otherThreadPath, "other");
...@@ -1072,6 +1082,7 @@ it.layer(...@@ -1072,6 +1082,7 @@ it.layer(
1072 });1082 });
10731083
1074 assert.isTrue(yield* exists(keepPath));1084 assert.isTrue(yield* exists(keepPath));
1085 assert.isTrue(yield* exists(keepFilePath));
1075 assert.isFalse(yield* exists(removePath));1086 assert.isFalse(yield* exists(removePath));
1076 assert.isTrue(yield* exists(otherThreadPath));1087 assert.isTrue(yield* exists(otherThreadPath));
1077 }),1088 }),
...@@ -1091,6 +1102,7 @@ it.layer(Layer.fresh(makeProjectionPipelinePrefixedTestLayer("t3-projection-atta...@@ -1091,6 +1102,7 @@ it.layer(Layer.fresh(makeProjectionPipelinePrefixedTestLayer("t3-projection-atta
1091 const now = "2026-01-01T00:00:00.000Z";1102 const now = "2026-01-01T00:00:00.000Z";
1092 const threadId = ThreadId.make("Thread Delete.Files");1103 const threadId = ThreadId.make("Thread Delete.Files");
1093 const attachmentId = "thread-delete-files-00000000-0000-4000-8000-000000000001";1104 const attachmentId = "thread-delete-files-00000000-0000-4000-8000-000000000001";
1105 const fileAttachmentId = "thread-delete-files-00000000-0000-4000-8000-000000000003-pdf";
1094 const otherThreadAttachmentId =1106 const otherThreadAttachmentId =
1095 "thread-delete-files-extra-00000000-0000-4000-8000-000000000002";1107 "thread-delete-files-extra-00000000-0000-4000-8000-000000000002";
10961108
...@@ -1169,6 +1181,13 @@ it.layer(Layer.fresh(makeProjectionPipelinePrefixedTestLayer("t3-projection-atta...@@ -1169,6 +1181,13 @@ it.layer(Layer.fresh(makeProjectionPipelinePrefixedTestLayer("t3-projection-atta
1169 mimeType: "image/png",1181 mimeType: "image/png",
1170 sizeBytes: 5,1182 sizeBytes: 5,
1171 },1183 },
1184 {
1185 type: "file",
1186 id: fileAttachmentId,
1187 name: "delete.pdf",
1188 mimeType: "application/pdf",
1189 sizeBytes: 6,
1190 },
1172 ],1191 ],
1173 turnId: null,1192 turnId: null,
1174 streaming: false,1193 streaming: false,
...@@ -1178,14 +1197,17 @@ it.layer(Layer.fresh(makeProjectionPipelinePrefixedTestLayer("t3-projection-atta...@@ -1178,14 +1197,17 @@ it.layer(Layer.fresh(makeProjectionPipelinePrefixedTestLayer("t3-projection-atta
1178 });1197 });
11791198
1180 const threadAttachmentPath = path.join(attachmentsDir, `${attachmentId}.png`);1199 const threadAttachmentPath = path.join(attachmentsDir, `${attachmentId}.png`);
1200 const threadFileAttachmentPath = path.join(attachmentsDir, `${fileAttachmentId}.pdf`);
1181 const otherThreadAttachmentPath = path.join(1201 const otherThreadAttachmentPath = path.join(
1182 attachmentsDir,1202 attachmentsDir,
1183 `${otherThreadAttachmentId}.png`,1203 `${otherThreadAttachmentId}.png`,
1184 );1204 );
1185 yield* fileSystem.makeDirectory(attachmentsDir, { recursive: true });1205 yield* fileSystem.makeDirectory(attachmentsDir, { recursive: true });
1186 yield* fileSystem.writeFileString(threadAttachmentPath, "delete");1206 yield* fileSystem.writeFileString(threadAttachmentPath, "delete");
1207 yield* fileSystem.writeFileString(threadFileAttachmentPath, "delete");
1187 yield* fileSystem.writeFileString(otherThreadAttachmentPath, "other-thread");1208 yield* fileSystem.writeFileString(otherThreadAttachmentPath, "other-thread");
1188 assert.isTrue(yield* exists(threadAttachmentPath));1209 assert.isTrue(yield* exists(threadAttachmentPath));
1210 assert.isTrue(yield* exists(threadFileAttachmentPath));
1189 assert.isTrue(yield* exists(otherThreadAttachmentPath));1211 assert.isTrue(yield* exists(otherThreadAttachmentPath));
11901212
1191 yield* appendAndProject({1213 yield* appendAndProject({
...@@ -1205,6 +1227,7 @@ it.layer(Layer.fresh(makeProjectionPipelinePrefixedTestLayer("t3-projection-atta...@@ -1205,6 +1227,7 @@ it.layer(Layer.fresh(makeProjectionPipelinePrefixedTestLayer("t3-projection-atta
1205 });1227 });
12061228
1207 assert.isFalse(yield* exists(threadAttachmentPath));1229 assert.isFalse(yield* exists(threadAttachmentPath));
1230 assert.isFalse(yield* exists(threadFileAttachmentPath));
1208 assert.isTrue(yield* exists(otherThreadAttachmentPath));1231 assert.isTrue(yield* exists(otherThreadAttachmentPath));
1209 }),1232 }),
1210 );1233 );
apps/server/src/orchestration/Layers/ProjectionPipeline.ts+4-4
...@@ -361,14 +361,14 @@ function collectThreadAttachmentRelativePaths(...@@ -361,14 +361,14 @@ function collectThreadAttachmentRelativePaths(
361 const relativePaths = new Set<string>();361 const relativePaths = new Set<string>();
362 for (const message of messages) {362 for (const message of messages) {
363 for (const attachment of message.attachments ?? []) {363 for (const attachment of message.attachments ?? []) {
364 if (attachment.type !== "image") {
365 continue;
366 }
367 const attachmentThreadSegment = parseThreadSegmentFromAttachmentId(attachment.id);364 const attachmentThreadSegment = parseThreadSegmentFromAttachmentId(attachment.id);
368 if (!attachmentThreadSegment || attachmentThreadSegment !== threadSegment) {365 if (!attachmentThreadSegment || attachmentThreadSegment !== threadSegment) {
369 continue;366 continue;
370 }367 }
371 relativePaths.add(attachmentRelativePath(attachment));368 const relativePath = attachmentRelativePath(attachment);
369 if (relativePath) {
370 relativePaths.add(relativePath);
371 }
372 }372 }
373 }373 }
374 return relativePaths;374 return relativePaths;
apps/server/src/orchestration/Normalizer.attachments.test.ts+46-4
...@@ -93,9 +93,12 @@ describe("normalizeDispatchCommand attachments", () => {...@@ -93,9 +93,12 @@ describe("normalizeDispatchCommand attachments", () => {
93 expect(attachmentId.startsWith("thread-1-")).toBe(true);93 expect(attachmentId.startsWith("thread-1-")).toBe(true);
94 expect(attachmentId).not.toBe(`thread-1-${attachmentUuid}`);94 expect(attachmentId).not.toBe(`thread-1-${attachmentUuid}`);
95 expect(NodeFS.existsSync(pendingPath)).toBe(true);95 expect(NodeFS.existsSync(pendingPath)).toBe(true);
96 expect(NodeFS.existsSync(NodePath.join(config.attachmentsDir, `${attachmentId}.png`))).toBe(96 const claimedPngPath = NodePath.join(config.attachmentsDir, `${attachmentId}.png`);
97 true,97 expect(NodeFS.existsSync(claimedPngPath)).toBe(true);
98 );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);
99 }).pipe(Effect.provide(testLayer)),102 }).pipe(Effect.provide(testLayer)),
100 );103 );
101104
...@@ -124,6 +127,45 @@ describe("normalizeDispatchCommand attachments", () => {...@@ -124,6 +127,45 @@ describe("normalizeDispatchCommand attachments", () => {
124 }).pipe(Effect.provide(testLayer)),127 }).pipe(Effect.provide(testLayer)),
125 );128 );
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
127 it.effect("retries a failed bootstrap with a fresh thread id", () =>169 it.effect("retries a failed bootstrap with a fresh thread id", () =>
128 Effect.gen(function* () {170 Effect.gen(function* () {
129 const config = yield* ServerConfig.ServerConfig;171 const config = yield* ServerConfig.ServerConfig;
...@@ -312,7 +354,7 @@ describe("normalizeDispatchCommand attachments", () => {...@@ -312,7 +354,7 @@ describe("normalizeDispatchCommand attachments", () => {
312 })),354 })),
313 },355 },
314 }).pipe(Effect.flip);356 }).pipe(Effect.flip);
315 expect(mismatchedType.message).toContain("image type");357 expect(mismatchedType.message).toContain("attachment type");
316 }).pipe(Effect.provide(testLayer)),358 }).pipe(Effect.provide(testLayer)),
317 );359 );
318});360});
apps/server/src/orchestration/Normalizer.ts+4-2
...@@ -176,12 +176,14 @@ export const normalizeDispatchCommand = (command: ClientOrchestrationCommand) =>...@@ -176,12 +176,14 @@ export const normalizeDispatchCommand = (command: ClientOrchestrationCommand) =>
176 });176 });
177 if (expectedPath !== claim.finalPath) {177 if (expectedPath !== claim.finalPath) {
178 return yield* new OrchestrationDispatchCommandError({178 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.`,
180 });180 });
181 }181 }
182182
183 // Keep the pending copy until the turn succeeds. A failed thread183 // 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.
185 yield* fileSystem.copyFile(claim.currentPath, claim.finalPath).pipe(187 yield* fileSystem.copyFile(claim.currentPath, claim.finalPath).pipe(
186 Effect.mapError(188 Effect.mapError(
187 (cause) =>189 (cause) =>
apps/server/src/provider/Layers/ClaudeAdapter.test.ts+1-1
...@@ -808,7 +808,7 @@ describe("ClaudeAdapterLive", () => {...@@ -808,7 +808,7 @@ describe("ClaudeAdapterLive", () => {
808 mimeType: "image/png",808 mimeType: "image/png",
809 sizeBytes: 4,809 sizeBytes: 4,
810 };810 };
811 const attachmentPath = NodePath.join(attachmentsDir, attachmentRelativePath(attachment));811 const attachmentPath = NodePath.join(attachmentsDir, attachmentRelativePath(attachment)!);
812 NodeFS.mkdirSync(NodePath.dirname(attachmentPath), { recursive: true });812 NodeFS.mkdirSync(NodePath.dirname(attachmentPath), { recursive: true });
813 NodeFS.writeFileSync(attachmentPath, Uint8Array.from([1, 2, 3, 4]));813 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* (...@@ -1306,6 +1306,8 @@ const buildUserMessageEffect = Effect.fn("buildUserMessageEffect")(function* (
1306 }1306 }
13071307
1308 for (const attachment of input.attachments ?? []) {1308 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.
1309 if (attachment.type !== "image") {1311 if (attachment.type !== "image") {
1310 continue;1312 continue;
1311 }1313 }
apps/server/src/provider/Layers/CodexAdapter.ts+4-1
...@@ -1822,8 +1822,11 @@ export const makeCodexAdapter = Effect.fn("makeCodexAdapter")(function* (...@@ -1822,8 +1822,11 @@ export const makeCodexAdapter = Effect.fn("makeCodexAdapter")(function* (
1822 });1822 });
18231823
1824 const sendTurn: CodexAdapterShape["sendTurn"] = Effect.fn("sendTurn")(function* (input) {1824 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.
1825 const codexAttachments = yield* Effect.forEach(1828 const codexAttachments = yield* Effect.forEach(
1826 input.attachments ?? [],1829 (input.attachments ?? []).filter((attachment) => attachment.type === "image"),
1827 (attachment) => resolveAttachment(input, attachment),1830 (attachment) => resolveAttachment(input, attachment),
1828 { concurrency: 1 },1831 { concurrency: 1 },
1829 );1832 );
apps/server/src/provider/Layers/CursorAdapter.ts+5
...@@ -972,6 +972,11 @@ export function makeCursorAdapter(...@@ -972,6 +972,11 @@ export function makeCursorAdapter(
972 }972 }
973 if (input.attachments && input.attachments.length > 0) {973 if (input.attachments && input.attachments.length > 0) {
974 for (const attachment of input.attachments) {974 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 }
975 const attachmentPath = resolveAttachmentPath({980 const attachmentPath = resolveAttachmentPath({
976 attachmentsDir: serverConfig.attachmentsDir,981 attachmentsDir: serverConfig.attachmentsDir,
977 attachment,982 attachment,
apps/server/src/provider/Layers/GrokAdapter.ts+3-1
...@@ -1504,8 +1504,10 @@ export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapte...@@ -1504,8 +1504,10 @@ export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapte
1504 );1504 );
15051505
1506 const text = input.input?.trim();1506 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.
1507 const imagePromptParts = yield* Effect.forEach(1509 const imagePromptParts = yield* Effect.forEach(
1508 input.attachments ?? [],1510 (input.attachments ?? []).filter((attachment) => attachment.type === "image"),
1509 (attachment) =>1511 (attachment) =>
1510 Effect.gen(function* () {1512 Effect.gen(function* () {
1511 const attachmentPath = resolveAttachmentPath({1513 const attachmentPath = resolveAttachmentPath({
apps/server/src/provider/Layers/OpenCodeAdapter.ts+2
...@@ -1456,6 +1456,8 @@ export function makeOpenCodeAdapter(...@@ -1456,6 +1456,8 @@ export function makeOpenCodeAdapter(
1456 }1456 }
14571457
1458 const text = input.input?.trim();1458 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.
1459 const fileParts = toOpenCodeFileParts({1461 const fileParts = toOpenCodeFileParts({
1460 attachments: input.attachments,1462 attachments: input.attachments,
1461 resolveAttachmentPath: (attachment) =>1463 resolveAttachmentPath: (attachment) =>
apps/server/src/provider/Layers/ProviderService.test.ts+27-1
...@@ -14,7 +14,6 @@ import type {...@@ -14,7 +14,6 @@ import type {
14} from "@t3tools/contracts";14} from "@t3tools/contracts";
15import {15import {
16 ApprovalRequestId,16 ApprovalRequestId,
17 EnvironmentId,
18 EventId,17 EventId,
19 ProviderDriverKind,18 ProviderDriverKind,
20 ProviderInstanceId,19 ProviderInstanceId,
...@@ -1145,6 +1144,33 @@ routing.layer("ProviderServiceLive routing", (it) => {...@@ -1145,6 +1144,33 @@ routing.layer("ProviderServiceLive routing", (it) => {
1145 const imageOnlyInput = routing.codex.sendTurn.mock.calls[0]?.[0] as ProviderSendTurnInput;1144 const imageOnlyInput = routing.codex.sendTurn.mock.calls[0]?.[0] as ProviderSendTurnInput;
1146 assert.equal(imageOnlyInput.input?.startsWith('[Attached image "screenshot.png"'), true);1145 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
1148 yield* provider.stopSession({ threadId: session.threadId });1174 yield* provider.stopSession({ threadId: session.threadId });
1149 }),1175 }),
1150 );1176 );
apps/server/src/provider/Layers/ProviderService.ts+8-10
...@@ -729,13 +729,12 @@ const makeProviderService = Effect.fn("makeProviderService")(function* (...@@ -729,13 +729,12 @@ const makeProviderService = Effect.fn("makeProviderService")(function* (
729 );729 );
730 }730 }
731731
732 // Adapters inline attachment pixels into the model prompt, but the model's732 // Every attachment gets an on-disk path in the prompt so the model's tools
733 // tools cannot dereference pixels. Appending the on-disk path is what lets733 // can dereference the actual file. All attachments then go to the adapter,
734 // a turn like "include this screenshot in the PR" copy the actual file.734 // and each adapter decides what its provider ingests natively: OpenCode
735 // This runs after schema decode, so the appended lines are exempt from the735 // sends generic files as file parts, the others send images only and rely
736 // PROVIDER_SEND_TURN_MAX_INPUT_CHARS check; attachment count is capped, so736 // on the path line for everything else. Unresolvable ids are skipped here
737 // the overhead is bounded. Unresolvable ids are skipped here and surface737 // and surface as adapter errors when the file is read.
738 // as adapter errors when the file is read for inlining.
739 const attachmentPathLines = attachments.flatMap((attachment) => {738 const attachmentPathLines = attachments.flatMap((attachment) => {
740 const attachmentPath = resolveAttachmentPath({739 const attachmentPath = resolveAttachmentPath({
741 attachmentsDir: serverConfig.attachmentsDir,740 attachmentsDir: serverConfig.attachmentsDir,
...@@ -757,13 +756,12 @@ const makeProviderService = Effect.fn("makeProviderService")(function* (...@@ -757,13 +756,12 @@ const makeProviderService = Effect.fn("makeProviderService")(function* (
757 ...(inputTextWithAttachmentPaths !== undefined756 ...(inputTextWithAttachmentPaths !== undefined
758 ? { input: inputTextWithAttachmentPaths }757 ? { input: inputTextWithAttachmentPaths }
759 : {}),758 : {}),
760 attachments,
761 };759 };
762 yield* Effect.annotateCurrentSpan({760 yield* Effect.annotateCurrentSpan({
763 "provider.operation": "send-turn",761 "provider.operation": "send-turn",
764 "provider.thread_id": input.threadId,762 "provider.thread_id": input.threadId,
765 "provider.interaction_mode": input.interactionMode,763 "provider.interaction_mode": input.interactionMode,
766 "provider.attachment_count": input.attachments.length,764 "provider.attachment_count": attachments.length,
767 });765 });
768 let metricProvider = "unknown";766 let metricProvider = "unknown";
769 let metricModel = input.modelSelection?.model;767 let metricModel = input.modelSelection?.model;
...@@ -807,7 +805,7 @@ const makeProviderService = Effect.fn("makeProviderService")(function* (...@@ -807,7 +805,7 @@ const makeProviderService = Effect.fn("makeProviderService")(function* (
807 // often, since every toggle restarts the session. Recording it per turn805 // often, since every toggle restarts the session. Recording it per turn
808 // gives a usage-weighted view and lets it cross with interactionMode.806 // gives a usage-weighted view and lets it cross with interactionMode.
809 runtimeMode: routed.runtimeMode,807 runtimeMode: routed.runtimeMode,
810 attachmentCount: input.attachments.length,808 attachmentCount: attachments.length,
811 hasInput: typeof input.input === "string" && input.input.trim().length > 0,809 hasInput: typeof input.input === "string" && input.input.trim().length > 0,
812 });810 });
813 return turn;811 return turn;
apps/server/src/provider/opencodeRuntime.cliParsers.test.ts+36
...@@ -6,6 +6,7 @@ import {...@@ -6,6 +6,7 @@ import {
6 parseAgentListCliOutput,6 parseAgentListCliOutput,
7 parseModelsCliOutput,7 parseModelsCliOutput,
8 parseSkillsCliOutput,8 parseSkillsCliOutput,
9 toOpenCodeFileParts,
9} from "./opencodeRuntime.ts";10} from "./opencodeRuntime.ts";
1011
11describe("parseModelsCliOutput", () => {12describe("parseModelsCliOutput", () => {
...@@ -283,3 +284,38 @@ describe("parseSkillsCliOutput", () => {...@@ -283,3 +284,38 @@ describe("parseSkillsCliOutput", () => {
283 NodeAssert.deepEqual(parseSkillsCliOutput("not json"), []);284 NodeAssert.deepEqual(parseSkillsCliOutput("not json"), []);
284 });285 });
285});286});
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(...@@ -345,6 +345,31 @@ export function openCodeQuestionId(
345 return header.length > 0 ? `question-${index}-${header}` : `question-${index}`;345 return header.length > 0 ? `question-${index}-${header}` : `question-${index}`;
346}346}
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
348export function toOpenCodeFileParts(input: {373export function toOpenCodeFileParts(input: {
349 readonly attachments: ReadonlyArray<ChatAttachment> | undefined;374 readonly attachments: ReadonlyArray<ChatAttachment> | undefined;
350 readonly resolveAttachmentPath: (attachment: ChatAttachment) => string | null;375 readonly resolveAttachmentPath: (attachment: ChatAttachment) => string | null;
...@@ -352,6 +377,9 @@ export function toOpenCodeFileParts(input: {...@@ -352,6 +377,9 @@ export function toOpenCodeFileParts(input: {
352 const parts: Array<FilePartInput> = [];377 const parts: Array<FilePartInput> = [];
353378
354 for (const attachment of input.attachments ?? []) {379 for (const attachment of input.attachments ?? []) {
380 if (!isOpenCodeNativeFilePart(attachment)) {
381 continue;
382 }
355 const attachmentPath = input.resolveAttachmentPath(attachment);383 const attachmentPath = input.resolveAttachmentPath(attachment);
356 if (!attachmentPath) {384 if (!attachmentPath) {
357 continue;385 continue;
apps/server/src/server.test.ts+107
...@@ -4579,6 +4579,113 @@ it.layer(NodeServices.layer)("server router seam", (it) => {...@@ -4579,6 +4579,113 @@ it.layer(NodeServices.layer)("server router seam", (it) => {
4579 });4579 });
4580 assert.equal(streamedResponse.status, 204);4580 assert.equal(streamedResponse.status, 204);
4581 yield* client[WS_METHODS.attachmentsDelete]({ attachmentId: streamed.attachmentId });4581 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), []);
4582 }),4689 }),
4583 ),4690 ),
4584 );4691 );
apps/server/src/textGeneration/OpenCodeTextGeneration.test.ts+43-1
...@@ -18,6 +18,7 @@ const runtimeMock = {...@@ -18,6 +18,7 @@ const runtimeMock = {
18 state: {18 state: {
19 startCalls: [] as string[],19 startCalls: [] as string[],
20 promptUrls: [] as string[],20 promptUrls: [] as string[],
21 promptParts: [] as ReadonlyArray<unknown>[],
21 authHeaders: [] as Array<string | null>,22 authHeaders: [] as Array<string | null>,
22 closeCalls: [] as string[],23 closeCalls: [] as string[],
23 sessionCreateError: undefined as unknown,24 sessionCreateError: undefined as unknown,
...@@ -30,6 +31,7 @@ const runtimeMock = {...@@ -30,6 +31,7 @@ const runtimeMock = {
30 reset() {31 reset() {
31 this.state.startCalls.length = 0;32 this.state.startCalls.length = 0;
32 this.state.promptUrls.length = 0;33 this.state.promptUrls.length = 0;
34 this.state.promptParts.length = 0;
33 this.state.authHeaders.length = 0;35 this.state.authHeaders.length = 0;
34 this.state.closeCalls.length = 0;36 this.state.closeCalls.length = 0;
35 this.state.sessionCreateError = undefined;37 this.state.sessionCreateError = undefined;
...@@ -73,8 +75,9 @@ const OpenCodeRuntimeTestDouble: OpenCodeRuntime.OpenCodeRuntimeShape = {...@@ -73,8 +75,9 @@ const OpenCodeRuntimeTestDouble: OpenCodeRuntime.OpenCodeRuntimeShape = {
73 }75 }
74 return runtimeMock.state.sessionResult ?? { data: { id: `${baseUrl}/session` } };76 return runtimeMock.state.sessionResult ?? { data: { id: `${baseUrl}/session` } };
75 },77 },
76 prompt: async () => {78 prompt: async (input: { readonly parts: ReadonlyArray<unknown> }) => {
77 runtimeMock.state.promptUrls.push(baseUrl);79 runtimeMock.state.promptUrls.push(baseUrl);
80 runtimeMock.state.promptParts.push(input.parts);
78 runtimeMock.state.authHeaders.push(81 runtimeMock.state.authHeaders.push(
79 serverPassword ? `Basic ${btoa(`opencode:${serverPassword}`)}` : null,82 serverPassword ? `Basic ${btoa(`opencode:${serverPassword}`)}` : null,
80 );83 );
...@@ -187,6 +190,45 @@ const advanceIdleClock = Effect.gen(function* () {...@@ -187,6 +190,45 @@ const advanceIdleClock = Effect.gen(function* () {
187});190});
188191
189it.layer(OpenCodeTextGenerationTestLayer)("OpenCodeTextGeneration", (it) => {192it.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
190 it.effect("reuses a warm server across back-to-back requests and closes it after idling", () =>232 it.effect("reuses a warm server across back-to-back requests and closes it after idling", () =>
191 withOpenCodeTextGeneration(DEFAULT_OPENCODE_SETTINGS, (textGeneration) =>233 withOpenCodeTextGeneration(DEFAULT_OPENCODE_SETTINGS, (textGeneration) =>
192 Effect.gen(function* () {234 Effect.gen(function* () {
apps/server/src/textGeneration/OpenCodeTextGeneration.ts+1-1
...@@ -375,7 +375,7 @@ export const makeOpenCodeTextGeneration = Effect.fn("makeOpenCodeTextGeneration"...@@ -375,7 +375,7 @@ export const makeOpenCodeTextGeneration = Effect.fn("makeOpenCodeTextGeneration"
375 }375 }
376376
377 const fileParts = OpenCodeRuntime.toOpenCodeFileParts({377 const fileParts = OpenCodeRuntime.toOpenCodeFileParts({
378 attachments: input.attachments,378 attachments: input.attachments?.filter((attachment) => attachment.type === "image"),
379 resolveAttachmentPath: (attachment) =>379 resolveAttachmentPath: (attachment) =>
380 resolveAttachmentPath({ attachmentsDir: serverConfig.attachmentsDir, attachment }),380 resolveAttachmentPath({ attachmentsDir: serverConfig.attachmentsDir, attachment }),
381 });381 });
apps/web/src/components/ChatView.logic.ts+9-3
...@@ -11,7 +11,13 @@ import {...@@ -11,7 +11,13 @@ import {
11 type ThreadId,11 type ThreadId,
12 type TurnId,12 type TurnId,
13} from "@t3tools/contracts";13} 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";
15import { type ComposerImageAttachment, type DraftThreadState } from "../composerDraftStore";21import { type ComposerImageAttachment, type DraftThreadState } from "../composerDraftStore";
16import * as Schema from "effect/Schema";22import * as Schema from "effect/Schema";
17import { appAtomRegistry } from "../rpc/atomRegistry";23import { appAtomRegistry } from "../rpc/atomRegistry";
...@@ -277,7 +283,7 @@ export function revokeUserMessagePreviewUrls(message: ChatMessage): void {...@@ -277,7 +283,7 @@ export function revokeUserMessagePreviewUrls(message: ChatMessage): void {
277 return;283 return;
278 }284 }
279 for (const attachment of message.attachments) {285 for (const attachment of message.attachments) {
280 if (attachment.type !== "image") {286 if (!isImageAttachment(attachment)) {
281 continue;287 continue;
282 }288 }
283 revokeBlobPreviewUrl(attachment.previewUrl);289 revokeBlobPreviewUrl(attachment.previewUrl);
...@@ -290,7 +296,7 @@ export function collectUserMessageBlobPreviewUrls(message: ChatMessage): string[...@@ -290,7 +296,7 @@ export function collectUserMessageBlobPreviewUrls(message: ChatMessage): string[
290 }296 }
291 const previewUrls: string[] = [];297 const previewUrls: string[] = [];
292 for (const attachment of message.attachments) {298 for (const attachment of message.attachments) {
293 if (attachment.type !== "image") continue;299 if (!isImageAttachment(attachment)) continue;
294 if (!attachment.previewUrl || !attachment.previewUrl.startsWith("blob:")) continue;300 if (!attachment.previewUrl || !attachment.previewUrl.startsWith("blob:")) continue;
295 previewUrls.push(attachment.previewUrl);301 previewUrls.push(attachment.previewUrl);
296 }302 }
apps/web/src/components/ChatView.tsx+3-2
...@@ -126,6 +126,7 @@ import {...@@ -126,6 +126,7 @@ import {
126 DEFAULT_THREAD_TERMINAL_ID,126 DEFAULT_THREAD_TERMINAL_ID,
127 MAX_TERMINALS_PER_GROUP,127 MAX_TERMINALS_PER_GROUP,
128 type ChatMessage,128 type ChatMessage,
129 isImageAttachment,
129 type SessionPhase,130 type SessionPhase,
130 type Thread,131 type Thread,
131 type TurnDiffSummary,132 type TurnDiffSummary,
...@@ -2560,7 +2561,7 @@ function ChatViewContent(props: ChatViewProps) {...@@ -2560,7 +2561,7 @@ function ChatViewContent(props: ChatViewProps) {
2560 }2561 }
25612562
2562 const serverPreviewUrls = serverMessage.attachments.flatMap((attachment) =>2563 const serverPreviewUrls = serverMessage.attachments.flatMap((attachment) =>
2563 attachment.type === "image" && attachment.previewUrl ? [attachment.previewUrl] : [],2564 isImageAttachment(attachment) && attachment.previewUrl ? [attachment.previewUrl] : [],
2564 );2565 );
2565 if (2566 if (
2566 serverPreviewUrls.length === 0 ||2567 serverPreviewUrls.length === 0 ||
...@@ -2643,7 +2644,7 @@ function ChatViewContent(props: ChatViewProps) {...@@ -2643,7 +2644,7 @@ function ChatViewContent(props: ChatViewProps) {
2643 let changed = false;2644 let changed = false;
2644 let imageIndex = 0;2645 let imageIndex = 0;
2645 const attachments = message.attachments.map((attachment) => {2646 const attachments = message.attachments.map((attachment) => {
2646 if (attachment.type !== "image") {2647 if (!isImageAttachment(attachment)) {
2647 return attachment;2648 return attachment;
2648 }2649 }
2649 const handoffPreviewUrl = handoffPreviewUrls[imageIndex];2650 const handoffPreviewUrl = handoffPreviewUrls[imageIndex];
apps/web/src/components/chat/MessagesTimeline.tsx+4-6
...@@ -38,7 +38,7 @@ import {...@@ -38,7 +38,7 @@ import {
38 workEntrySignalsSevereFailure,38 workEntrySignalsSevereFailure,
39 workLogEntryIsToolLike,39 workLogEntryIsToolLike,
40} from "../../session-logic";40} from "../../session-logic";
41import { type TurnDiffSummary } from "../../types";41import { type ChatImageAttachment, isImageAttachment, type TurnDiffSummary } from "../../types";
42import {42import {
43 getRenderablePatch,43 getRenderablePatch,
44 resolveDiffThemeName,44 resolveDiffThemeName,
...@@ -925,8 +925,6 @@ function TimelineMinimap({...@@ -925,8 +925,6 @@ function TimelineMinimap({
925// TimelineRowContent — the actual row component925// TimelineRowContent — the actual row component
926// ---------------------------------------------------------------------------926// ---------------------------------------------------------------------------
927927
928type TimelineEntry = ReturnType<typeof deriveTimelineEntries>[number];
929type TimelineMessage = Extract<TimelineEntry, { kind: "message" }>["message"];
930type TimelineWorkEntry = Extract<MessagesTimelineRow, { kind: "work" }>["groupedEntries"][number];928type TimelineWorkEntry = Extract<MessagesTimelineRow, { kind: "work" }>["groupedEntries"][number];
931type TimelineRow = MessagesTimelineRow;929type TimelineRow = MessagesTimelineRow;
932930
...@@ -988,7 +986,7 @@ const TimelineRowContent = memo(function TimelineRowContent({ row }: { row: Time...@@ -988,7 +986,7 @@ const TimelineRowContent = memo(function TimelineRowContent({ row }: { row: Time
988986
989function UserTimelineRow({ row }: { row: Extract<TimelineRow, { kind: "message" }> }) {987function UserTimelineRow({ row }: { row: Extract<TimelineRow, { kind: "message" }> }) {
990 const ctx = use(TimelineRowCtx);988 const ctx = use(TimelineRowCtx);
991 const userImages = row.message.attachments ?? [];989 const userImages = (row.message.attachments ?? []).filter(isImageAttachment);
992 const displayedUserMessage = deriveDisplayedUserMessageState(row.message.text);990 const displayedUserMessage = deriveDisplayedUserMessageState(row.message.text);
993 const terminalContexts = displayedUserMessage.contexts;991 const terminalContexts = displayedUserMessage.contexts;
994 const previewAnnotations: ParsedPreviewAnnotation[] = [];992 const previewAnnotations: ParsedPreviewAnnotation[] = [];
...@@ -1013,7 +1011,7 @@ function UserTimelineRow({ row }: { row: Extract<TimelineRow, { kind: "message"...@@ -1013,7 +1011,7 @@ function UserTimelineRow({ row }: { row: Extract<TimelineRow, { kind: "message"
1013 <div className="relative max-w-[80%] rounded-2xl bg-message p-3 text-message-foreground">1011 <div className="relative max-w-[80%] rounded-2xl bg-message p-3 text-message-foreground">
1014 {regularImages.length > 0 && (1012 {regularImages.length > 0 && (
1015 <div className="mb-2 grid max-w-[420px] grid-cols-2 gap-2">1013 <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) => (
1017 <div1015 <div
1018 key={image.id}1016 key={image.id}
1019 className="overflow-hidden rounded-lg border border-border/80 bg-background/70"1017 className="overflow-hidden rounded-lg border border-border/80 bg-background/70"
...@@ -1719,7 +1717,7 @@ const UserMessageElementContextChip = memo(function UserMessageElementContextChi...@@ -1719,7 +1717,7 @@ const UserMessageElementContextChip = memo(function UserMessageElementContextChi
17191717
1720function UserMessagePreviewAnnotationCard(props: {1718function UserMessagePreviewAnnotationCard(props: {
1721 annotation: ParsedPreviewAnnotation;1719 annotation: ParsedPreviewAnnotation;
1722 image: NonNullable<TimelineMessage["attachments"]>[number] | null;1720 image: ChatImageAttachment | null;
1723}) {1721}) {
1724 const ctx = use(TimelineRowCtx);1722 const ctx = use(TimelineRowCtx);
1725 return (1723 return (
apps/web/src/types.ts+12-1
...@@ -1,4 +1,5 @@...@@ -1,4 +1,5 @@
1import type {1import type {
2 ChatAttachment as ContractChatAttachment,
2 ChatImageAttachment as ContractChatImageAttachment,3 ChatImageAttachment as ContractChatImageAttachment,
3 OrchestrationCheckpointFile,4 OrchestrationCheckpointFile,
4 OrchestrationCheckpointSummary,5 OrchestrationCheckpointSummary,
...@@ -35,7 +36,17 @@ export interface ChatImageAttachment extends ContractChatImageAttachment {...@@ -35,7 +36,17 @@ export interface ChatImageAttachment extends ContractChatImageAttachment {
35 readonly previewUrl?: string;36 readonly previewUrl?: string;
36}37}
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
40export interface ChatMessage extends Omit<OrchestrationMessage, "attachments"> {51export interface ChatMessage extends Omit<OrchestrationMessage, "attachments"> {
41 readonly attachments?: ReadonlyArray<ChatAttachment> | undefined;52 readonly attachments?: ReadonlyArray<ChatAttachment> | undefined;
docs/internals/providers.md+28
...@@ -51,6 +51,34 @@ probes, respect the `enableProviderUpdateChecks` setting, and never fail a provi...@@ -51,6 +51,34 @@ probes, respect the `enableProviderUpdateChecks` setting, and never fail a provi
51Codex and Claude drivers apply the classification to every snapshot with `applyModelManifest`;51Codex and Claude drivers apply the classification to every snapshot with `applyModelManifest`;
52driver kinds absent from the manifest have no legacy concept.52driver 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
54## How provider work is requested82## How provider work is requested
5583
56Clients never call a provider directly. They dispatch orchestration commands over the RPC method84Clients 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";...@@ -2,7 +2,10 @@ import * as Schema from "effect/Schema";
2import { describe, expect, it } from "vite-plus/test";2import { describe, expect, it } from "vite-plus/test";
33
4import { AttachmentCreateUploadUrlInput } from "./assets.ts";4import { 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
7const isUploadInput = Schema.is(AttachmentCreateUploadUrlInput);10const isUploadInput = Schema.is(AttachmentCreateUploadUrlInput);
811
...@@ -21,10 +24,37 @@ describe("AttachmentCreateUploadUrlInput", () => {...@@ -21,10 +24,37 @@ describe("AttachmentCreateUploadUrlInput", () => {
21 expect(isUploadInput({ ...uploadInput, mimeType: "image/svg+xml" })).toBe(false);24 expect(isUploadInput({ ...uploadInput, mimeType: "image/svg+xml" })).toBe(false);
22 });25 });
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
24 it("rejects empty and oversized uploads", () => {46 it("rejects empty and oversized uploads", () => {
25 expect(isUploadInput({ ...uploadInput, sizeBytes: 0 })).toBe(false);47 expect(isUploadInput({ ...uploadInput, sizeBytes: 0 })).toBe(false);
26 expect(48 expect(
27 isUploadInput({ ...uploadInput, sizeBytes: PROVIDER_SEND_TURN_MAX_IMAGE_BYTES + 1 }),49 isUploadInput({ ...uploadInput, sizeBytes: PROVIDER_SEND_TURN_MAX_IMAGE_BYTES + 1 }),
28 ).toBe(false);50 ).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);
29 });59 });
30});60});
packages/contracts/src/assets.ts+24-1
...@@ -2,6 +2,7 @@ import * as Schema from "effect/Schema";...@@ -2,6 +2,7 @@ import * as Schema from "effect/Schema";
22
3import { NonNegativeInt, ThreadId, TrimmedNonEmptyString } from "./baseSchemas.ts";3import { NonNegativeInt, ThreadId, TrimmedNonEmptyString } from "./baseSchemas.ts";
4import {4import {
5 PROVIDER_SEND_TURN_MAX_FILE_BYTES,
5 PROVIDER_SEND_TURN_MAX_IMAGE_BYTES,6 PROVIDER_SEND_TURN_MAX_IMAGE_BYTES,
6 PROVIDER_SEND_TURN_SUPPORTED_IMAGE_MIME_TYPES,7 PROVIDER_SEND_TURN_SUPPORTED_IMAGE_MIME_TYPES,
7 ProjectFaviconPath,8 ProjectFaviconPath,
...@@ -16,6 +17,12 @@ export const AssetResource = Schema.Union([...@@ -16,6 +17,12 @@ export const AssetResource = Schema.Union([
16 }),17 }),
17 Schema.TaggedStruct("attachment", {18 Schema.TaggedStruct("attachment", {
18 attachmentId: TrimmedNonEmptyString.check(Schema.isMaxLength(256)),19 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))),
19 }),26 }),
20 Schema.TaggedStruct("project-favicon", {27 Schema.TaggedStruct("project-favicon", {
21 cwd: TrimmedNonEmptyString.check(Schema.isMaxLength(ASSET_PATH_MAX_LENGTH)),28 cwd: TrimmedNonEmptyString.check(Schema.isMaxLength(ASSET_PATH_MAX_LENGTH)),
...@@ -42,7 +49,8 @@ export type AssetCreateUrlResult = typeof AssetCreateUrlResult.Type;...@@ -42,7 +49,8 @@ export type AssetCreateUrlResult = typeof AssetCreateUrlResult.Type;
4249
43export const ATTACHMENT_UPLOAD_URL_TTL_MS = 10 * 60_000;50export 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")),
46 name: TrimmedNonEmptyString.check(Schema.isMaxLength(255)),54 name: TrimmedNonEmptyString.check(Schema.isMaxLength(255)),
47 mimeType: Schema.Literals(PROVIDER_SEND_TURN_SUPPORTED_IMAGE_MIME_TYPES),55 mimeType: Schema.Literals(PROVIDER_SEND_TURN_SUPPORTED_IMAGE_MIME_TYPES),
48 sizeBytes: NonNegativeInt.check(56 sizeBytes: NonNegativeInt.check(
...@@ -50,6 +58,21 @@ export const AttachmentCreateUploadUrlInput = Schema.Struct({...@@ -50,6 +58,21 @@ export const AttachmentCreateUploadUrlInput = Schema.Struct({
50 Schema.isLessThanOrEqualTo(PROVIDER_SEND_TURN_MAX_IMAGE_BYTES),58 Schema.isLessThanOrEqualTo(PROVIDER_SEND_TURN_MAX_IMAGE_BYTES),
51 ),59 ),
52});60});
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]);
53export type AttachmentCreateUploadUrlInput = typeof AttachmentCreateUploadUrlInput.Type;76export type AttachmentCreateUploadUrlInput = typeof AttachmentCreateUploadUrlInput.Type;
5477
55export const AttachmentCreateUploadUrlResult = Schema.Struct({78export const AttachmentCreateUploadUrlResult = Schema.Struct({
packages/contracts/src/environment.test.ts+12
...@@ -39,4 +39,16 @@ describe("ExecutionEnvironmentDescriptor", () => {...@@ -39,4 +39,16 @@ describe("ExecutionEnvironmentDescriptor", () => {
39 }).capabilities.attachmentUploads,39 }).capabilities.attachmentUploads,
40 ).toBe(true);40 ).toBe(true);
41 });41 });
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 });
42});54});
packages/contracts/src/environment.ts+6
...@@ -50,6 +50,12 @@ export const ExecutionEnvironmentCapabilities = Schema.Struct({...@@ -50,6 +50,12 @@ export const ExecutionEnvironmentCapabilities = Schema.Struct({
50 connectionProbe: Schema.optionalKey(Schema.Boolean),50 connectionProbe: Schema.optionalKey(Schema.Boolean),
51 /** Missing on older servers, which still accept inline image attachments. */51 /** Missing on older servers, which still accept inline image attachments. */
52 attachmentUploads: Schema.optionalKey(Schema.Boolean),52 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 ),
53 /** Server exposes the pull-request list, detail, activity, diff, and mutation APIs. Absent on59 /** Server exposes the pull-request list, detail, activity, diff, and mutation APIs. Absent on
54 servers from before the pull-request workspace shipped, so clients must not probe them. */60 servers from before the pull-request workspace shipped, so clients must not probe them. */
55 pullRequests: Schema.optionalKey(Schema.Boolean),61 pullRequests: Schema.optionalKey(Schema.Boolean),
packages/contracts/src/orchestration.test.ts+91-2
...@@ -1,5 +1,6 @@...@@ -1,5 +1,6 @@
1import { assert, it } from "@effect/vitest";1import { assert, it } from "@effect/vitest";
2import * as Effect from "effect/Effect";2import * as Effect from "effect/Effect";
3import * as Exit from "effect/Exit";
3import * as Schema from "effect/Schema";4import * as Schema from "effect/Schema";
45
5import {6import {
...@@ -20,12 +21,15 @@ import {...@@ -20,12 +21,15 @@ import {
20 OrchestrationThread,21 OrchestrationThread,
21 OrchestrationThreadShell,22 OrchestrationThreadShell,
22 ProjectCreateCommand,23 ProjectCreateCommand,
24 OrchestrationMessage,
25 ThreadMessageSentPayload,
23 ThreadMetaUpdatedPayload,26 ThreadMetaUpdatedPayload,
24 ThreadTurnStartCommand,27 ThreadTurnStartCommand,
25 ThreadCreatedPayload,28 ThreadCreatedPayload,
26 ThreadTurnDiff,29 ThreadTurnDiff,
27 ThreadTurnStartRequestedPayload,30 ThreadTurnStartRequestedPayload,
28 isProviderSendTurnSupportedImageMimeType,31 isProviderSendTurnSupportedImageMimeType,
32 PROVIDER_SEND_TURN_MAX_FILE_BYTES,
29} from "./orchestration.ts";33} from "./orchestration.ts";
30import { ProviderInstanceId } from "./providerInstance.ts";34import { ProviderInstanceId } from "./providerInstance.ts";
3135
...@@ -37,6 +41,8 @@ const decodeProjectCreatedPayload = Schema.decodeUnknownEffect(ProjectCreatedPay...@@ -37,6 +41,8 @@ const decodeProjectCreatedPayload = Schema.decodeUnknownEffect(ProjectCreatedPay
37const decodeProjectMetaUpdatedPayload = Schema.decodeUnknownEffect(ProjectMetaUpdatedPayload);41const decodeProjectMetaUpdatedPayload = Schema.decodeUnknownEffect(ProjectMetaUpdatedPayload);
38const decodeThreadTurnStartCommand = Schema.decodeUnknownEffect(ThreadTurnStartCommand);42const decodeThreadTurnStartCommand = Schema.decodeUnknownEffect(ThreadTurnStartCommand);
39const decodeClientOrchestrationCommand = Schema.decodeUnknownEffect(ClientOrchestrationCommand);43const decodeClientOrchestrationCommand = Schema.decodeUnknownEffect(ClientOrchestrationCommand);
44const decodeOrchestrationMessage = Schema.decodeUnknownEffect(OrchestrationMessage);
45const decodeThreadMessageSentPayload = Schema.decodeUnknownEffect(ThreadMessageSentPayload);
40const decodeThreadTurnStartRequestedPayload = Schema.decodeUnknownEffect(46const decodeThreadTurnStartRequestedPayload = Schema.decodeUnknownEffect(
41 ThreadTurnStartRequestedPayload,47 ThreadTurnStartRequestedPayload,
42);48);
...@@ -243,7 +249,7 @@ it.effect("decodes thread.turn.start defaults for provider and runtime mode", ()...@@ -243,7 +249,7 @@ it.effect("decodes thread.turn.start defaults for provider and runtime mode", ()
243 }),249 }),
244);250);
245251
246it.effect("accepts both inline and uploaded image attachments from clients", () =>252it.effect("accepts inline images, uploaded images, and uploaded files from clients", () =>
247 Effect.gen(function* () {253 Effect.gen(function* () {
248 const command = yield* decodeClientOrchestrationCommand({254 const command = yield* decodeClientOrchestrationCommand({
249 type: "thread.turn.start",255 type: "thread.turn.start",
...@@ -268,6 +274,13 @@ it.effect("accepts both inline and uploaded image attachments from clients", ()...@@ -268,6 +274,13 @@ it.effect("accepts both inline and uploaded image attachments from clients", ()
268 mimeType: "image/png",274 mimeType: "image/png",
269 sizeBytes: 3,275 sizeBytes: 3,
270 },276 },
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 },
271 ],284 ],
272 },285 },
273 runtimeMode: "full-access",286 runtimeMode: "full-access",
...@@ -278,9 +291,85 @@ it.effect("accepts both inline and uploaded image attachments from clients", ()...@@ -278,9 +291,85 @@ it.effect("accepts both inline and uploaded image attachments from clients", ()
278 if (command.type !== "thread.turn.start") {291 if (command.type !== "thread.turn.start") {
279 assert.fail(`Expected thread.turn.start, received ${command.type}.`);292 assert.fail(`Expected thread.turn.start, received ${command.type}.`);
280 }293 }
281 assert.strictEqual(command.message.attachments.length, 2);294 assert.strictEqual(command.message.attachments.length, 3);
282 assert.strictEqual("dataUrl" in command.message.attachments[0]!, true);295 assert.strictEqual("dataUrl" in command.message.attachments[0]!, true);
283 assert.strictEqual("id" in command.message.attachments[1]!, true);296 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);
284 }),373 }),
285);374);
286375
packages/contracts/src/orchestration.ts+40-1
...@@ -156,6 +156,7 @@ export type ProviderUserInputAnswers = typeof ProviderUserInputAnswers.Type;...@@ -156,6 +156,7 @@ export type ProviderUserInputAnswers = typeof ProviderUserInputAnswers.Type;
156export const PROVIDER_SEND_TURN_MAX_INPUT_CHARS = 120_000;156export const PROVIDER_SEND_TURN_MAX_INPUT_CHARS = 120_000;
157export const PROVIDER_SEND_TURN_MAX_ATTACHMENTS = 8;157export const PROVIDER_SEND_TURN_MAX_ATTACHMENTS = 8;
158export const PROVIDER_SEND_TURN_MAX_IMAGE_BYTES = 10 * 1024 * 1024;158export const PROVIDER_SEND_TURN_MAX_IMAGE_BYTES = 10 * 1024 * 1024;
159export const PROVIDER_SEND_TURN_MAX_FILE_BYTES = 50 * 1024 * 1024;
159export const PROVIDER_SEND_TURN_SUPPORTED_IMAGE_MIME_TYPES = [160export const PROVIDER_SEND_TURN_SUPPORTED_IMAGE_MIME_TYPES = [
160 "image/gif",161 "image/gif",
161 "image/jpeg",162 "image/jpeg",
...@@ -191,6 +192,40 @@ export const ChatImageAttachment = Schema.Struct({...@@ -191,6 +192,40 @@ export const ChatImageAttachment = Schema.Struct({
191});192});
192export type ChatImageAttachment = typeof ChatImageAttachment.Type;193export 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
194const UploadChatImageAttachment = Schema.Struct({229const UploadChatImageAttachment = Schema.Struct({
195 type: Schema.Literal("image"),230 type: Schema.Literal("image"),
196 name: TrimmedNonEmptyString.check(Schema.isMaxLength(255)),231 name: TrimmedNonEmptyString.check(Schema.isMaxLength(255)),
...@@ -202,7 +237,11 @@ const UploadChatImageAttachment = Schema.Struct({...@@ -202,7 +237,11 @@ const UploadChatImageAttachment = Schema.Struct({
202});237});
203export type UploadChatImageAttachment = typeof UploadChatImageAttachment.Type;238export type UploadChatImageAttachment = typeof UploadChatImageAttachment.Type;
204239
205export const ChatAttachment = Schema.Union([ChatImageAttachment]);240export const ChatAttachment = Schema.Union([
241 ChatImageAttachment,
242 ChatFileAttachment,
243 ChatUnknownAttachment,
244]);
206export type ChatAttachment = typeof ChatAttachment.Type;245export type ChatAttachment = typeof ChatAttachment.Type;
207const UploadChatAttachment = Schema.Union([UploadChatImageAttachment]);246const UploadChatAttachment = Schema.Union([UploadChatImageAttachment]);
208export type UploadChatAttachment = typeof UploadChatAttachment.Type;247export type UploadChatAttachment = typeof UploadChatAttachment.Type;