| author | |
| committer | |
| log | 8f49132214a40c85cf46bf5e3d8ea11c04a9610a |
| tree | fbacb523a1ef1f7b9de13ffb96b2fa2057f1e4b3 |
| parent | 88be5631ff6a48b98ead48fa5af6a2be0a4d63e4 |
| signature | Signed by PGP key B5690EEEBB952194 |
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 whose | 1047 | // A bubble that sizes itself from its content cannot lay out a block whose |
| 1046 | // intrinsic width overflows `maxWidth`: Android positions the bubble's | 1048 | // 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"; |
| 39 | import * as ServerSecretStore from "../auth/ServerSecretStore.ts"; | 39 | import * as ServerSecretStore from "../auth/ServerSecretStore.ts"; |
| 40 | import { resolveAttachmentPathById } from "../attachmentStore.ts"; | 40 | import { parseAttachmentFileExtension, resolveAttachmentPathById } from "../attachmentStore.ts"; |
| 41 | import * as ServerConfig from "../config.ts"; | 41 | import * as ServerConfig from "../config.ts"; |
| 42 | import * as ProjectFaviconResolver from "../project/ProjectFaviconResolver.ts"; | 42 | import * as ProjectFaviconResolver from "../project/ProjectFaviconResolver.ts"; |
| 43 | import * as WorkspacePaths from "../workspace/WorkspacePaths.ts"; | 43 | import * 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); |
| 101 | const decodeAssetClaims = Schema.decodeUnknownOption(AssetClaimsJson); | 108 | const decodeAssetClaims = Schema.decodeUnknownOption(AssetClaimsJson); |
| 102 | const encodeAssetClaims = Schema.encodeSync(AssetClaimsJson); | 109 | const encodeAssetClaims = Schema.encodeSync(AssetClaimsJson); |
| 103 | 110 | ||
| 104 | export type ResolvedAsset = { readonly kind: "file"; readonly path: string }; | 111 | export type ResolvedAsset = { |
| 112 | readonly kind: "file"; | ||
| 113 | readonly path: string; | ||
| 114 | readonly download?: boolean; | ||
| 115 | readonly fileName?: string; | ||
| 116 | readonly mimeType?: string; | ||
| 117 | }; | ||
| 105 | 118 | ||
| 106 | function decodeClaims(encodedPayload: string): AssetClaims | null { | 119 | function 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 | } |
| 470 | 496 |
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"; |
| 4 | 4 | ||
| 5 | import * as NodeServices from "@effect/platform-node/NodeServices"; | 5 | import * as NodeServices from "@effect/platform-node/NodeServices"; |
| 6 | import { describe, expect, it } from "@effect/vitest"; | 6 | import { describe, expect, it } from "@effect/vitest"; |
| 7 | import * as Deferred from "effect/Deferred"; | ||
| 7 | import * as Effect from "effect/Effect"; | 8 | import * as Effect from "effect/Effect"; |
| 9 | import * as Fiber from "effect/Fiber"; | ||
| 8 | import * as Layer from "effect/Layer"; | 10 | import * as Layer from "effect/Layer"; |
| 11 | import * as Schema from "effect/Schema"; | ||
| 12 | import * as Stream from "effect/Stream"; | ||
| 9 | import * as TestClock from "effect/testing/TestClock"; | 13 | import * as TestClock from "effect/testing/TestClock"; |
| 10 | 14 | ||
| 11 | import * as ServerSecretStore from "../auth/ServerSecretStore.ts"; | 15 | import * as ServerSecretStore from "../auth/ServerSecretStore.ts"; |
| 16 | import { base64UrlEncode, signPayload } from "../auth/utils.ts"; | ||
| 12 | import * as ServerConfig from "../config.ts"; | 17 | import * as ServerConfig from "../config.ts"; |
| 13 | import { parseThreadSegmentFromAttachmentId } from "../attachmentStore.ts"; | 18 | import { parseThreadSegmentFromAttachmentId } from "../attachmentStore.ts"; |
| 14 | import { | 19 | import { |
| ... | @@ -30,6 +35,19 @@ const uploadInput = { | ... | @@ -30,6 +35,19 @@ const uploadInput = { |
| 30 | sizeBytes: 6, | 35 | sizeBytes: 6, |
| 31 | } as const; | 36 | } as const; |
| 32 | 37 | ||
| 38 | const 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 | }); | ||
| 47 | const encodeLegacyAttachmentUploadClaims = Schema.encodeEffect( | ||
| 48 | Schema.fromJsonString(LegacyAttachmentUploadClaims), | ||
| 49 | ); | ||
| 50 | |||
| 33 | describe("AttachmentUpload", () => { | 51 | describe("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 | ); |
| 61 | 79 | ||
| 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 | ); |
| 110 | 153 | ||
| 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"; |
| 12 | import * as Option from "effect/Option"; | 12 | import * as Option from "effect/Option"; |
| 13 | import * as Path from "effect/Path"; | 13 | import * as Path from "effect/Path"; |
| 14 | import * as Schema from "effect/Schema"; | 14 | import * as Schema from "effect/Schema"; |
| 15 | import * as Stream from "effect/Stream"; | ||
| 16 | import type * as HttpServerRequest from "effect/unstable/http/HttpServerRequest"; | ||
| 15 | 17 | ||
| 16 | import { | 18 | import { |
| 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>(); |
| 41 | const AttachmentUploadClaims = Schema.Struct({ | 44 | const 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 | } |
| 91 | 97 | ||
| 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 = |
| 141 | 151 | ||
| 142 | export const storeAttachmentUpload = Effect.fn("AttachmentUpload.store")(function* ( | 152 | export 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 | } |
| 153 | 163 | ||
| 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 |
| 168 | 181 | ||
| 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 | }); |
| 195 | 224 |
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"; |
| 6 | import { describe, expect, it } from "vite-plus/test"; | 6 | import { describe, expect, it } from "vite-plus/test"; |
| 7 | 7 | ||
| 8 | import { | 8 | import { |
| 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 | }); |
| 60 | 62 | ||
| 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 | }); |
| 94 | 111 | ||
| 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 | } |
| 156 | 189 | ||
| 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"]; |
| 15 | const ATTACHMENT_ID_THREAD_SEGMENT_MAX_CHARS = 80; | 15 | const ATTACHMENT_ID_THREAD_SEGMENT_MAX_CHARS = 80; |
| 16 | const ATTACHMENT_ID_THREAD_SEGMENT_PATTERN = "[a-z0-9_]+(?:-[a-z0-9_]+)*"; | 16 | const ATTACHMENT_ID_THREAD_SEGMENT_PATTERN = "[a-z0-9_]+(?:-[a-z0-9_]+)*"; |
| 17 | const ATTACHMENT_ID_UUID_PATTERN = "[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}"; | 17 | const ATTACHMENT_ID_UUID_PATTERN = "[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}"; |
| 18 | const ATTACHMENT_ID_FILE_EXTENSION_PATTERN = "[a-z0-9]{1,10}"; | ||
| 18 | const ATTACHMENT_ID_PATTERN = new RegExp( | 19 | const 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 | ); |
| 22 | 23 | ||
| ... | @@ -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 | } |
| 41 | 42 | ||
| 42 | export function createPendingAttachmentId(): string { | 43 | export 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 | |||
| 53 | function 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 | |||
| 63 | export function createPendingAttachmentId(extension?: string): string { | ||
| 64 | return `${PENDING_ATTACHMENT_THREAD_SEGMENT}-${NodeCrypto.randomUUID()}${attachmentIdExtensionSuffix(extension)}`; | ||
| 44 | } | 65 | } |
| 45 | 66 | ||
| 46 | export function parseAttachmentUuid(attachmentId: string): string | null { | 67 | export 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 | } |
| 53 | 74 | ||
| 54 | export function createAttachmentId(threadId: string): string | null { | 75 | export 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 | |||
| 83 | export 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 | } |
| 61 | 90 | ||
| 62 | export function parseThreadSegmentFromAttachmentId(attachmentId: string): string | null { | 91 | export 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 | } |
| 73 | 102 | ||
| 74 | export function attachmentRelativePath(attachment: ChatAttachment): string { | 103 | /** Null for attachment types this build does not know; callers skip those. */ |
| 104 | export 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 | } |
| 85 | 119 | ||
| ... | @@ -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 | } |
| 95 | 133 | ||
| ... | @@ -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 @@ |
| 1 | import { EnvironmentId, type ExecutionEnvironmentDescriptor } from "@t3tools/contracts"; | 1 | import { |
| 2 | EnvironmentId, | ||
| 3 | PROVIDER_SEND_TURN_MAX_FILE_BYTES, | ||
| 4 | type ExecutionEnvironmentDescriptor, | ||
| 5 | } from "@t3tools/contracts"; | ||
| 2 | import { HostProcessArchitecture, HostProcessPlatform } from "@t3tools/shared/hostProcess"; | 6 | import { HostProcessArchitecture, HostProcessPlatform } from "@t3tools/shared/hostProcess"; |
| 3 | import * as Context from "effect/Context"; | 7 | import * as Context from "effect/Context"; |
| 4 | import * as Crypto from "effect/Crypto"; | 8 | import * 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 @@ |
| 1 | import { expect, it } from "@effect/vitest"; | 1 | import { expect, it } from "@effect/vitest"; |
| 2 | import { describe } from "vite-plus/test"; | 2 | import { describe } from "vite-plus/test"; |
| 3 | 3 | ||
| 4 | import { assetResponseHeaders, isLoopbackHostname, resolveDevRedirectUrl } from "./http.ts"; | 4 | import { |
| 5 | assetResponseHeaders, | ||
| 6 | downloadContentDisposition, | ||
| 7 | isLoopbackHostname, | ||
| 8 | resolveDevRedirectUrl, | ||
| 9 | } from "./http.ts"; | ||
| 5 | 10 | ||
| 6 | describe("http dev routing", () => { | 11 | describe("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 | |||
| 118 | describe("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"; |
| 12 | import * as Layer from "effect/Layer"; | 12 | import * as Layer from "effect/Layer"; |
| 13 | import * as Option from "effect/Option"; | 13 | import * as Option from "effect/Option"; |
| 14 | import * as Path from "effect/Path"; | 14 | import * as Path from "effect/Path"; |
| 15 | import * as Stream from "effect/Stream"; | ||
| 15 | import { cast } from "effect/Function"; | 16 | import { cast } from "effect/Function"; |
| 16 | import { | 17 | import { |
| 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"]); |
| 50 | const DESKTOP_RENDERER_ORIGINS = ["t3code://app", "t3code-dev://app"]; | 51 | const DESKTOP_RENDERER_ORIGINS = ["t3code://app", "t3code-dev://app"]; |
| 51 | const SVG_CONTENT_SECURITY_POLICY = "default-src 'none'; style-src 'unsafe-inline'; sandbox"; | 52 | const SVG_CONTENT_SECURITY_POLICY = "default-src 'none'; style-src 'unsafe-inline'; sandbox"; |
| 52 | 53 | ||
| 53 | export 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. | ||
| 56 | const DOWNLOAD_MIME_TYPE_PATTERN = /^[\w!#$&^.+-]+\/[\w!#$&^.+-]+$/; | ||
| 57 | const 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*`. */ | ||
| 62 | export 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 | |||
| 80 | export 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 | } |
| 267 | 319 | ||
| 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.ok | 323 | 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 | }); |
| 1046 | 1054 | ||
| 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 | }); |
| 1073 | 1083 | ||
| 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"; |
| 1096 | 1108 | ||
| ... | @@ -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 | }); |
| 1179 | 1198 | ||
| 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)); |
| 1190 | 1212 | ||
| 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 | }); |
| 1206 | 1228 | ||
| 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 | ); |
| 101 | 104 | ||
| ... | @@ -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 | ); |
| 126 | 129 | ||
| 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 | } |
| 182 | 182 | ||
| 183 | // Keep the pending copy until the turn succeeds. A failed thread | 183 | // 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])); |
| 814 | 814 |
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 | } |
| 1307 | 1307 | ||
| 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 | }); |
| 1823 | 1823 | ||
| 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 | ); |
| 1505 | 1505 | ||
| 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 | } |
| 1457 | 1457 | ||
| 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"; |
| 15 | import { | 15 | import { |
| 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); |
| 1147 | 1146 | ||
| 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 | } |
| 731 | 731 | ||
| 732 | // Adapters inline attachment pixels into the model prompt, but the model's | 732 | // 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 lets | 733 | // 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 the | 735 | // 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, so | 736 | // on the path line for everything else. Unresolvable ids are skipped here |
| 737 | // the overhead is bounded. Unresolvable ids are skipped here and surface | 737 | // 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 !== undefined | 756 | ...(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 turn | 805 | // 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"; |
| 10 | 11 | ||
| 11 | describe("parseModelsCliOutput", () => { | 12 | describe("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 | |||
| 288 | describe("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 | } |
| 347 | 347 | ||
| 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 | */ | ||
| 355 | const OPENCODE_NATIVE_IMAGE_MIMES = new Set(["image/png", "image/jpeg", "image/gif", "image/webp"]); | ||
| 356 | export const OPENCODE_NATIVE_FILE_PART_MAX_BYTES = 20 * 1024 * 1024; | ||
| 357 | |||
| 358 | export 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 | |||
| 348 | export function toOpenCodeFileParts(input: { | 373 | export 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> = []; |
| 353 | 378 | ||
| 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 | }); |
| 188 | 191 | ||
| 189 | it.layer(OpenCodeTextGenerationTestLayer)("OpenCodeTextGeneration", (it) => { | 192 | it.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 | } |
| 376 | 376 | ||
| 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"; |
| 14 | import { type ChatMessage, type SessionPhase, type Thread, type ThreadShell } from "../types"; | 14 | import { |
| 15 | type ChatMessage, | ||
| 16 | isImageAttachment, | ||
| 17 | type SessionPhase, | ||
| 18 | type Thread, | ||
| 19 | type ThreadShell, | ||
| 20 | } from "../types"; | ||
| 15 | import { type ComposerImageAttachment, type DraftThreadState } from "../composerDraftStore"; | 21 | import { type ComposerImageAttachment, type DraftThreadState } from "../composerDraftStore"; |
| 16 | import * as Schema from "effect/Schema"; | 22 | import * as Schema from "effect/Schema"; |
| 17 | import { appAtomRegistry } from "../rpc/atomRegistry"; | 23 | import { 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 | } |
| 2561 | 2562 | ||
| 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"; |
| 41 | import { type TurnDiffSummary } from "../../types"; | 41 | import { type ChatImageAttachment, isImageAttachment, type TurnDiffSummary } from "../../types"; |
| 42 | import { | 42 | import { |
| 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 component | 925 | // TimelineRowContent — the actual row component |
| 926 | // --------------------------------------------------------------------------- | 926 | // --------------------------------------------------------------------------- |
| 927 | 927 | ||
| 928 | type TimelineEntry = ReturnType<typeof deriveTimelineEntries>[number]; | ||
| 929 | type TimelineMessage = Extract<TimelineEntry, { kind: "message" }>["message"]; | ||
| 930 | type TimelineWorkEntry = Extract<MessagesTimelineRow, { kind: "work" }>["groupedEntries"][number]; | 928 | type TimelineWorkEntry = Extract<MessagesTimelineRow, { kind: "work" }>["groupedEntries"][number]; |
| 931 | type TimelineRow = MessagesTimelineRow; | 929 | type TimelineRow = MessagesTimelineRow; |
| 932 | 930 | ||
| ... | @@ -988,7 +986,7 @@ const TimelineRowContent = memo(function TimelineRowContent({ row }: { row: Time | ... | @@ -988,7 +986,7 @@ const TimelineRowContent = memo(function TimelineRowContent({ row }: { row: Time |
| 988 | 986 | ||
| 989 | function UserTimelineRow({ row }: { row: Extract<TimelineRow, { kind: "message" }> }) { | 987 | function 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 | <div | 1015 | <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 |
| 1719 | 1717 | ||
| 1720 | function UserMessagePreviewAnnotationCard(props: { | 1718 | function 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 @@ |
| 1 | import type { | 1 | import 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 | } |
| 37 | 38 | ||
| 38 | export 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. | ||
| 41 | export 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. | ||
| 47 | export function isImageAttachment(attachment: ChatAttachment): attachment is ChatImageAttachment { | ||
| 48 | return attachment.type === "image"; | ||
| 49 | } | ||
| 39 | 50 | ||
| 40 | export interface ChatMessage extends Omit<OrchestrationMessage, "attachments"> { | 51 | export 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 |
| 51 | Codex and Claude drivers apply the classification to every snapshot with `applyModelManifest`; | 51 | Codex and Claude drivers apply the classification to every snapshot with `applyModelManifest`; |
| 52 | driver kinds absent from the manifest have no legacy concept. | 52 | driver kinds absent from the manifest have no legacy concept. |
| 53 | 53 | ||
| 54 | ## Attachment access | ||
| 55 | |||
| 56 | The 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 | ||
| 58 | attachment 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 | |||
| 66 | Claude receives the attachment directory as an allowed additional directory. Codex keeps its | ||
| 67 | configured sandbox policy, so access depends on that policy and the selected runtime mode. OpenCode | ||
| 68 | allows all paths in full-access mode and requests approval for directories outside the workspace in | ||
| 69 | restricted modes. Cursor and Grok use their own provider permission rules. | ||
| 70 | |||
| 71 | The server does not copy attachments into a project or bypass provider approval rules. If an agent | ||
| 72 | cannot read an attachment, the user must approve the access or select a runtime mode that permits it. | ||
| 73 | |||
| 74 | Updated attachment schemas tolerate unknown attachment members, but old image-only clients still | ||
| 75 | cannot decode messages that contain file attachments. Client file-picking rollouts must account for | ||
| 76 | this limit. | ||
| 77 | |||
| 78 | Do not run an old image-only server against state that contains file attachments. Replay decodes | ||
| 79 | each persisted event before projection. A file-bearing event can make `ProjectionPipeline` bootstrap | ||
| 80 | and `OrchestrationEngine` startup fail for the entire environment, not only the affected thread. | ||
| 81 | |||
| 54 | ## How provider work is requested | 82 | ## How provider work is requested |
| 55 | 83 | ||
| 56 | Clients never call a provider directly. They dispatch orchestration commands over the RPC method | 84 | Clients 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"; |
| 2 | import { describe, expect, it } from "vite-plus/test"; | 2 | import { describe, expect, it } from "vite-plus/test"; |
| 3 | 3 | ||
| 4 | import { AttachmentCreateUploadUrlInput } from "./assets.ts"; | 4 | import { AttachmentCreateUploadUrlInput } from "./assets.ts"; |
| 5 | import { PROVIDER_SEND_TURN_MAX_IMAGE_BYTES } from "./orchestration.ts"; | 5 | import { |
| 6 | PROVIDER_SEND_TURN_MAX_FILE_BYTES, | ||
| 7 | PROVIDER_SEND_TURN_MAX_IMAGE_BYTES, | ||
| 8 | } from "./orchestration.ts"; | ||
| 6 | 9 | ||
| 7 | const isUploadInput = Schema.is(AttachmentCreateUploadUrlInput); | 10 | const isUploadInput = Schema.is(AttachmentCreateUploadUrlInput); |
| 8 | 11 | ||
| ... | @@ -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 | }); |
| 23 | 26 | ||
| 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"; |
| 2 | 2 | ||
| 3 | import { NonNegativeInt, ThreadId, TrimmedNonEmptyString } from "./baseSchemas.ts"; | 3 | import { NonNegativeInt, ThreadId, TrimmedNonEmptyString } from "./baseSchemas.ts"; |
| 4 | import { | 4 | import { |
| 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; |
| 42 | 49 | ||
| 43 | export const ATTACHMENT_UPLOAD_URL_TTL_MS = 10 * 60_000; | 50 | export const ATTACHMENT_UPLOAD_URL_TTL_MS = 10 * 60_000; |
| 44 | 51 | ||
| 45 | export const AttachmentCreateUploadUrlInput = Schema.Struct({ | 52 | const 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 | |||
| 62 | const 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 | |||
| 72 | export const AttachmentCreateUploadUrlInput = Schema.Union([ | ||
| 73 | ImageAttachmentCreateUploadUrlInput, | ||
| 74 | FileAttachmentCreateUploadUrlInput, | ||
| 75 | ]); | ||
| 53 | export type AttachmentCreateUploadUrlInput = typeof AttachmentCreateUploadUrlInput.Type; | 76 | export type AttachmentCreateUploadUrlInput = typeof AttachmentCreateUploadUrlInput.Type; |
| 54 | 77 | ||
| 55 | export const AttachmentCreateUploadUrlResult = Schema.Struct({ | 78 | export 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 on | 59 | /** 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 @@ |
| 1 | import { assert, it } from "@effect/vitest"; | 1 | import { assert, it } from "@effect/vitest"; |
| 2 | import * as Effect from "effect/Effect"; | 2 | import * as Effect from "effect/Effect"; |
| 3 | import * as Exit from "effect/Exit"; | ||
| 3 | import * as Schema from "effect/Schema"; | 4 | import * as Schema from "effect/Schema"; |
| 4 | 5 | ||
| 5 | import { | 6 | import { |
| ... | @@ -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"; |
| 30 | import { ProviderInstanceId } from "./providerInstance.ts"; | 34 | import { ProviderInstanceId } from "./providerInstance.ts"; |
| 31 | 35 | ||
| ... | @@ -37,6 +41,8 @@ const decodeProjectCreatedPayload = Schema.decodeUnknownEffect(ProjectCreatedPay | ... | @@ -37,6 +41,8 @@ const decodeProjectCreatedPayload = Schema.decodeUnknownEffect(ProjectCreatedPay |
| 37 | const decodeProjectMetaUpdatedPayload = Schema.decodeUnknownEffect(ProjectMetaUpdatedPayload); | 41 | const decodeProjectMetaUpdatedPayload = Schema.decodeUnknownEffect(ProjectMetaUpdatedPayload); |
| 38 | const decodeThreadTurnStartCommand = Schema.decodeUnknownEffect(ThreadTurnStartCommand); | 42 | const decodeThreadTurnStartCommand = Schema.decodeUnknownEffect(ThreadTurnStartCommand); |
| 39 | const decodeClientOrchestrationCommand = Schema.decodeUnknownEffect(ClientOrchestrationCommand); | 43 | const decodeClientOrchestrationCommand = Schema.decodeUnknownEffect(ClientOrchestrationCommand); |
| 44 | const decodeOrchestrationMessage = Schema.decodeUnknownEffect(OrchestrationMessage); | ||
| 45 | const decodeThreadMessageSentPayload = Schema.decodeUnknownEffect(ThreadMessageSentPayload); | ||
| 40 | const decodeThreadTurnStartRequestedPayload = Schema.decodeUnknownEffect( | 46 | const 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 | ); |
| 245 | 251 | ||
| 246 | it.effect("accepts both inline and uploaded image attachments from clients", () => | 252 | it.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. | ||
| 304 | it.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. | ||
| 345 | it.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 | ); |
| 286 | 375 |
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; |
| 156 | export const PROVIDER_SEND_TURN_MAX_INPUT_CHARS = 120_000; | 156 | export const PROVIDER_SEND_TURN_MAX_INPUT_CHARS = 120_000; |
| 157 | export const PROVIDER_SEND_TURN_MAX_ATTACHMENTS = 8; | 157 | export const PROVIDER_SEND_TURN_MAX_ATTACHMENTS = 8; |
| 158 | export const PROVIDER_SEND_TURN_MAX_IMAGE_BYTES = 10 * 1024 * 1024; | 158 | export const PROVIDER_SEND_TURN_MAX_IMAGE_BYTES = 10 * 1024 * 1024; |
| 159 | export const PROVIDER_SEND_TURN_MAX_FILE_BYTES = 50 * 1024 * 1024; | ||
| 159 | export const PROVIDER_SEND_TURN_SUPPORTED_IMAGE_MIME_TYPES = [ | 160 | export 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 | }); |
| 192 | export type ChatImageAttachment = typeof ChatImageAttachment.Type; | 193 | export type ChatImageAttachment = typeof ChatImageAttachment.Type; |
| 193 | 194 | ||
| 195 | export 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 | }); | ||
| 205 | export 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 | */ | ||
| 217 | export 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 | }); | ||
| 227 | export type ChatUnknownAttachment = typeof ChatUnknownAttachment.Type; | ||
| 228 | |||
| 194 | const UploadChatImageAttachment = Schema.Struct({ | 229 | const 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 | }); |
| 203 | export type UploadChatImageAttachment = typeof UploadChatImageAttachment.Type; | 238 | export type UploadChatImageAttachment = typeof UploadChatImageAttachment.Type; |
| 204 | 239 | ||
| 205 | export const ChatAttachment = Schema.Union([ChatImageAttachment]); | 240 | export const ChatAttachment = Schema.Union([ |
| 241 | ChatImageAttachment, | ||
| 242 | ChatFileAttachment, | ||
| 243 | ChatUnknownAttachment, | ||
| 244 | ]); | ||
| 206 | export type ChatAttachment = typeof ChatAttachment.Type; | 245 | export type ChatAttachment = typeof ChatAttachment.Type; |
| 207 | const UploadChatAttachment = Schema.Union([UploadChatImageAttachment]); | 246 | const UploadChatAttachment = Schema.Union([UploadChatImageAttachment]); |
| 208 | export type UploadChatAttachment = typeof UploadChatAttachment.Type; | 247 | export type UploadChatAttachment = typeof UploadChatAttachment.Type; |