From 274cc8cc3c1ea150b49c3ea56fe6765efb727812 Mon Sep 17 00:00:00 2001 From: Bil0000 <62337003+Bil0000@users.noreply.github.com> Date: Thu, 24 Sep 2026 15:34:18 +0200 Subject: [PATCH 1/2] fix(contracts): read large file attachments from newer clients --- packages/contracts/src/chatAttachment.test.ts | 14 +++++++------- packages/contracts/src/chatAttachment.ts | 8 ++++---- 2 files changed, 11 insertions(+), 11 deletions(-) diff --git a/packages/contracts/src/chatAttachment.test.ts b/packages/contracts/src/chatAttachment.test.ts index 918f968763eb..326f9c4819e2 100644 --- a/packages/contracts/src/chatAttachment.test.ts +++ b/packages/contracts/src/chatAttachment.test.ts @@ -29,9 +29,6 @@ it.effect("tolerates attachment types from newer builds", () => }), ); -// The tolerant member must not catch malformed known attachments: a file over -// the size cap or an image with a bad mime has to fail its own schema, not -// slide through the open one with those constraints unchecked. it.effect("rejects malformed known attachment types instead of tolerating them", () => Effect.gen(function* () { const base = { @@ -39,10 +36,13 @@ it.effect("rejects malformed known attachment types instead of tolerating them", name: "report.pdf", mimeType: "application/pdf", }; - const oversizedFile = yield* Effect.exit( - decodeAttachment({ ...base, type: "file", sizeBytes: PROVIDER_SEND_TURN_MAX_FILE_BYTES + 1 }), - ); - assert.strictEqual(Exit.isFailure(oversizedFile), true); + // A newer build may raise the upload cap; this build must still read those files. + const aboveUploadCap = PROVIDER_SEND_TURN_MAX_FILE_BYTES + 1; + const largeFile = yield* decodeAttachment({ ...base, type: "file", sizeBytes: aboveUploadCap }); + assert.strictEqual(largeFile.sizeBytes, aboveUploadCap); + + const emptyFile = yield* Effect.exit(decodeAttachment({ ...base, type: "file", sizeBytes: 0 })); + assert.strictEqual(Exit.isFailure(emptyFile), true); const badMimeImage = yield* Effect.exit( decodeAttachment({ ...base, type: "image", mimeType: "application/pdf", sizeBytes: 12 }), ); diff --git a/packages/contracts/src/chatAttachment.ts b/packages/contracts/src/chatAttachment.ts index 48769c1a4728..369a211901b2 100644 --- a/packages/contracts/src/chatAttachment.ts +++ b/packages/contracts/src/chatAttachment.ts @@ -164,10 +164,10 @@ export const ChatFileAttachment = Schema.Struct({ id: ChatAttachmentId, name: TrimmedNonEmptyString.check(Schema.isMaxLength(255)), mimeType: TrimmedNonEmptyString.check(Schema.isMaxLength(100)), - sizeBytes: NonNegativeInt.check( - Schema.isGreaterThanOrEqualTo(1), - Schema.isLessThanOrEqualTo(PROVIDER_SEND_TURN_MAX_FILE_BYTES), - ), + /** No upper bound: history and thread streams can carry files that a newer + build accepted under a higher limit. `AttachmentCreateUploadUrlInput` + enforces the upload cap. */ + sizeBytes: NonNegativeInt.check(Schema.isGreaterThanOrEqualTo(1)), /** Clipboard text folded by a client. Providers keep these path-only so the agent can inspect the file selectively instead of eagerly spending the same context the fold is intended to preserve. */ From bdf0b92aecef65138d02fd42cf5922b9a3675d82 Mon Sep 17 00:00:00 2001 From: Bil0000 Date: Sun, 4 Oct 2026 15:13:43 +0300 Subject: [PATCH 2/2] fix(contracts): keep file size bounds on attachment requests --- packages/contracts/src/chatAttachment.test.ts | 119 ++++++++++++++++++ packages/contracts/src/chatAttachment.ts | 18 ++- packages/contracts/src/orchestrationV2.ts | 12 +- packages/contracts/src/provider.ts | 8 +- packages/contracts/src/providerPolicy.ts | 9 ++ 5 files changed, 153 insertions(+), 13 deletions(-) diff --git a/packages/contracts/src/chatAttachment.test.ts b/packages/contracts/src/chatAttachment.test.ts index 326f9c4819e2..356d30ff78a9 100644 --- a/packages/contracts/src/chatAttachment.test.ts +++ b/packages/contracts/src/chatAttachment.test.ts @@ -9,9 +9,128 @@ import { PROVIDER_SEND_TURN_MAX_FILE_BYTES, SnapShotAccessibility, } from "./chatAttachment.ts"; +import { + OrchestrationV2Command, + OrchestrationV2ConversationMessageJson, + OrchestrationV2ThreadLaunchInput, +} from "./orchestrationV2.ts"; +import { ProviderRespondToUserInputInput, ProviderSendTurnInput } from "./provider.ts"; +import { UserInputAttachmentAnswerPayload } from "./providerPolicy.ts"; const decodeAttachment = Schema.decodeUnknownEffect(ChatAttachment); const decodeSnapShotAccessibility = Schema.decodeUnknownEffect(SnapShotAccessibility); +const decodeQuestionAnswer = Schema.decodeUnknownSync(UserInputAttachmentAnswerPayload); +const decodeMessage = Schema.decodeUnknownSync(OrchestrationV2ConversationMessageJson); + +it.each([ + ["provider turn", ProviderSendTurnInput, {}], + [ + "provider question response", + ProviderRespondToUserInputInput, + { requestId: "request-1", answers: {} }, + ], + [ + "message dispatch", + OrchestrationV2Command, + { + type: "message.dispatch", + createdBy: "user", + creationSource: "web", + messageId: "message-1", + text: "hello", + dispatchMode: { type: "start_immediately" }, + }, + ], + [ + "queued message edit", + OrchestrationV2Command, + { type: "queued-run.edit", runId: "run-1", text: "hello" }, + ], + [ + "runtime question response", + OrchestrationV2Command, + { type: "runtime-request.respond", requestId: "request-1", answers: {} }, + ], + [ + "thread launch", + OrchestrationV2ThreadLaunchInput, + { + projectId: "project-1", + title: "Thread", + modelSelection: { instanceId: "codex", model: "gpt-5.4" }, + runtimeMode: "full-access", + interactionMode: "default", + workspaceStrategy: { type: "root" }, + }, + ], +] as const)("caps new file attachments in %s", (name, schema, fields) => { + const input = (sizeBytes: number) => { + const attachments = [ + { type: "file", id: "file-1", name: "report.pdf", mimeType: "application/pdf", sizeBytes }, + ]; + return { + commandId: "command-1", + threadId: "thread-1", + ...fields, + ...(name === "thread launch" + ? { initialMessage: { text: "hello", attachments } } + : name.includes("question response") + ? { attachmentsByQuestionId: { question: attachments } } + : { attachments }), + }; + }; + assert.strictEqual(Schema.is(schema)(input(PROVIDER_SEND_TURN_MAX_FILE_BYTES)), true); + assert.strictEqual(Schema.is(schema)(input(PROVIDER_SEND_TURN_MAX_FILE_BYTES + 1)), false); +}); + +it("reads historical question replies with files above the send limit", () => { + const payload = decodeQuestionAnswer({ + requestId: "request-1", + answers: {}, + attachmentsByQuestionId: { + question: [ + { + type: "file", + id: "file-1", + name: "report.pdf", + mimeType: "application/pdf", + sizeBytes: PROVIDER_SEND_TURN_MAX_FILE_BYTES + 1, + }, + ], + }, + }); + assert.strictEqual( + payload.attachmentsByQuestionId.question?.[0]?.sizeBytes, + PROVIDER_SEND_TURN_MAX_FILE_BYTES + 1, + ); +}); + +it("reads complete historical messages with files above the send limit", () => { + const message = decodeMessage({ + id: "message-1", + threadId: "thread-1", + runId: null, + nodeId: null, + createdBy: "user", + creationSource: "web", + role: "user", + text: "hello", + streaming: false, + createdAt: "2026-10-03T00:00:00.000Z", + updatedAt: "2026-10-03T00:00:00.000Z", + attachments: [ + { + type: "file", + id: "file-1", + name: "report.pdf", + mimeType: "application/pdf", + sizeBytes: PROVIDER_SEND_TURN_MAX_FILE_BYTES + 1, + }, + ], + }); + assert.strictEqual(message.attachments[0]?.sizeBytes, PROVIDER_SEND_TURN_MAX_FILE_BYTES + 1); + assert.strictEqual(message.text, "hello"); +}); // Attachments ride on persisted events and thread streams with no client // version negotiation. A type this build does not know must decode instead of diff --git a/packages/contracts/src/chatAttachment.ts b/packages/contracts/src/chatAttachment.ts index 369a211901b2..45887d38094a 100644 --- a/packages/contracts/src/chatAttachment.ts +++ b/packages/contracts/src/chatAttachment.ts @@ -164,9 +164,6 @@ export const ChatFileAttachment = Schema.Struct({ id: ChatAttachmentId, name: TrimmedNonEmptyString.check(Schema.isMaxLength(255)), mimeType: TrimmedNonEmptyString.check(Schema.isMaxLength(100)), - /** No upper bound: history and thread streams can carry files that a newer - build accepted under a higher limit. `AttachmentCreateUploadUrlInput` - enforces the upload cap. */ sizeBytes: NonNegativeInt.check(Schema.isGreaterThanOrEqualTo(1)), /** Clipboard text folded by a client. Providers keep these path-only so the agent can inspect the file selectively instead of eagerly spending the @@ -175,6 +172,14 @@ export const ChatFileAttachment = Schema.Struct({ }); export type ChatFileAttachment = typeof ChatFileAttachment.Type; +export const ChatFileAttachmentInput = Schema.Struct({ + ...ChatFileAttachment.fields, + sizeBytes: ChatFileAttachment.fields.sizeBytes.check( + Schema.isLessThanOrEqualTo(PROVIDER_SEND_TURN_MAX_FILE_BYTES), + ), +}); +export type ChatFileAttachmentInput = typeof ChatFileAttachmentInput.Type; + /** * Catch-all for attachment types this build does not know. Attachments ride on * persisted events and thread streams, so a newer server or client must be able @@ -217,6 +222,13 @@ export const ChatAttachment = Schema.Union([ ]); export type ChatAttachment = typeof ChatAttachment.Type; +export const ChatAttachmentInput = Schema.Union([ + ChatImageAttachment, + ChatFileAttachmentInput, + ChatUnknownAttachment, +]); +export type ChatAttachmentInput = typeof ChatAttachmentInput.Type; + export function getProviderAttachmentLimitError( attachments: ReadonlyArray>, ): string | undefined { diff --git a/packages/contracts/src/orchestrationV2.ts b/packages/contracts/src/orchestrationV2.ts index 6004ab119917..4035b2d7447b 100644 --- a/packages/contracts/src/orchestrationV2.ts +++ b/packages/contracts/src/orchestrationV2.ts @@ -31,7 +31,7 @@ import { TrimmedNonEmptyString, TurnItemId, } from "./baseSchemas.ts"; -import { ChatAttachment } from "./chatAttachment.ts"; +import { ChatAttachment, ChatAttachmentInput } from "./chatAttachment.ts"; import { OrchestrationGetFullThreadDiffInput, OrchestrationGetFullThreadDiffResult, @@ -54,7 +54,7 @@ import { ProviderInteractionMode, ProviderRequestKind, ProviderUserInputAnswers, - UserInputAttachments, + UserInputAttachmentsInput, UserInputAttachmentAnswerPayload, RuntimeMode, } from "./providerPolicy.ts"; @@ -2672,7 +2672,7 @@ export const OrchestrationV2Command = Schema.Union([ messageId: MessageId, text: Schema.String, context: Schema.optional(OrchestrationMessageContext), - attachments: Schema.Array(ChatAttachment), + attachments: Schema.Array(ChatAttachmentInput), /** Seed the temporary title and generate a durable replacement for the first message. */ titleSeed: Schema.optional(TrimmedNonEmptyString), modelSelection: Schema.optional(ModelSelection), @@ -2767,7 +2767,7 @@ export const OrchestrationV2Command = Schema.Union([ text: Schema.String, // Full replacement list. Absent = leave the message's attachments as-is, // so pre-attachment clients editing text keep the original attachments. - attachments: Schema.optional(Schema.Array(ChatAttachment)), + attachments: Schema.optional(Schema.Array(ChatAttachmentInput)), }), Schema.Struct({ type: Schema.Literal("runtime-request.respond"), @@ -2776,7 +2776,7 @@ export const OrchestrationV2Command = Schema.Union([ requestId: RuntimeRequestId, decision: Schema.optional(ProviderApprovalDecision), answers: Schema.optional(ProviderUserInputAnswers), - attachmentsByQuestionId: Schema.optional(UserInputAttachments), + attachmentsByQuestionId: Schema.optional(UserInputAttachmentsInput), }), Schema.Struct({ type: Schema.Literal("thread.user-input.dismiss"), @@ -2998,7 +2998,7 @@ export const OrchestrationV2ThreadLaunchInput = Schema.Struct({ messageId: Schema.optional(MessageId), text: Schema.String, context: Schema.optional(OrchestrationMessageContext), - attachments: Schema.Array(ChatAttachment), + attachments: Schema.Array(ChatAttachmentInput), }), ), }); diff --git a/packages/contracts/src/provider.ts b/packages/contracts/src/provider.ts index f48ca86fbf08..2c0191153783 100644 --- a/packages/contracts/src/provider.ts +++ b/packages/contracts/src/provider.ts @@ -11,7 +11,7 @@ import { import { getProviderAttachmentLimitError, PROVIDER_SEND_TURN_MAX_INPUT_CHARS, - ChatAttachment, + ChatAttachmentInput, } from "./chatAttachment.ts"; import { ModelSelection } from "./modelSelection.ts"; import { @@ -21,7 +21,7 @@ import { ProviderRequestKind, ProviderSandboxMode, ProviderUserInputAnswers, - UserInputAttachments, + UserInputAttachmentsInput, RuntimeMode, } from "./providerPolicy.ts"; import { ProviderInstanceId, ProviderDriverKind } from "./providerInstance.ts"; @@ -77,7 +77,7 @@ export const ProviderSendTurnInput = Schema.Struct({ TrimmedNonEmptyString.check(Schema.isMaxLength(PROVIDER_SEND_TURN_MAX_INPUT_CHARS)), ), attachments: Schema.optional( - Schema.Array(ChatAttachment).check( + Schema.Array(ChatAttachmentInput).check( Schema.makeFilter((attachments) => getProviderAttachmentLimitError(attachments) ?? true), ), ), @@ -115,7 +115,7 @@ export const ProviderRespondToUserInputInput = Schema.Struct({ threadId: ThreadId, requestId: ApprovalRequestId, answers: ProviderUserInputAnswers, - attachmentsByQuestionId: Schema.optional(UserInputAttachments), + attachmentsByQuestionId: Schema.optional(UserInputAttachmentsInput), }); export type ProviderRespondToUserInputInput = typeof ProviderRespondToUserInputInput.Type; diff --git a/packages/contracts/src/providerPolicy.ts b/packages/contracts/src/providerPolicy.ts index aa767e6dec1e..cae7dc1db8a7 100644 --- a/packages/contracts/src/providerPolicy.ts +++ b/packages/contracts/src/providerPolicy.ts @@ -1,6 +1,7 @@ import { ChatImageAttachment, ChatFileAttachment, + ChatFileAttachmentInput, PROVIDER_SEND_TURN_MAX_ATTACHMENTS, } from "./chatAttachment.ts"; import * as Schema from "effect/Schema"; @@ -75,6 +76,14 @@ export const UserInputAttachments = Schema.Record( ); export type UserInputAttachments = typeof UserInputAttachments.Type; +export const UserInputAttachmentsInput = Schema.Record( + Schema.String, + Schema.Array(Schema.Union([ChatImageAttachment, ChatFileAttachmentInput])).pipe( + Schema.check(Schema.isMaxLength(PROVIDER_SEND_TURN_MAX_ATTACHMENTS)), + ), +); +export type UserInputAttachmentsInput = typeof UserInputAttachmentsInput.Type; + export const UserInputAttachmentAnswerPayload = Schema.Struct({ requestId: TrimmedNonEmptyString, questionTextById: Schema.optional(Schema.Record(Schema.String, Schema.String)),