diff --git a/apps/mobile/src/features/sharing/incoming-share-model.test.ts b/apps/mobile/src/features/sharing/incoming-share-model.test.ts index ce2011650e71..01beb7824a8b 100644 --- a/apps/mobile/src/features/sharing/incoming-share-model.test.ts +++ b/apps/mobile/src/features/sharing/incoming-share-model.test.ts @@ -176,7 +176,7 @@ describe("incoming native shares", () => { }); expect(result.attachments).toEqual([]); - expect(result.warnings).toEqual(["'huge.zip' exceeds the 50 MB attachment limit."]); + expect(result.warnings).toEqual(["'huge.zip' exceeds the 100 MB attachment limit."]); expect(persistFile).not.toHaveBeenCalled(); expect(removeOwnedFile).toHaveBeenCalledWith(file.value); }); diff --git a/apps/mobile/src/lib/composerFiles.test.ts b/apps/mobile/src/lib/composerFiles.test.ts index a99c2c6064e7..c29543bb54c9 100644 --- a/apps/mobile/src/lib/composerFiles.test.ts +++ b/apps/mobile/src/lib/composerFiles.test.ts @@ -446,10 +446,10 @@ describe("composer file attachments", () => { }, { reason: "server advertises more than the contract limit", - reported: 51 * 1024 * 1024, + reported: 100_000_001, stored: 42, - limit: 80 * 1024 * 1024, - error: "'clip.mov' exceeds the 50 MB attachment limit.", + limit: 200_000_000, + error: "'clip.mov' exceeds the 100 MB attachment limit.", }, ])( "rejects a video when $reason while retaining the selected photo", @@ -676,7 +676,7 @@ describe("composer file attachments", () => { expect(mocks.copy).not.toHaveBeenCalled(); }); - it("never accepts files above the 50 MB contract limit", async () => { + it("never accepts files above the 100 MB contract limit", async () => { mocks.pickFile.mockResolvedValue({ canceled: false, assets: [ @@ -684,16 +684,14 @@ describe("composer file attachments", () => { uri: "file:///downloads/archive.zip", name: "archive.zip", mimeType: "application/zip", - size: 51 * 1024 * 1024, + size: 100_000_001, }, ], }); - await expect( - pickComposerFiles({ existingCount: 0, maxBytes: 80 * 1024 * 1024 }), - ).resolves.toEqual({ + await expect(pickComposerFiles({ existingCount: 0, maxBytes: 200_000_000 })).resolves.toEqual({ files: [], - error: "'archive.zip' exceeds the 50 MB attachment limit.", + error: "'archive.zip' exceeds the 100 MB attachment limit.", }); }); diff --git a/apps/server/src/assets/AttachmentUpload.test.ts b/apps/server/src/assets/AttachmentUpload.test.ts index 6fffa1d1f9f2..7046e1937526 100644 --- a/apps/server/src/assets/AttachmentUpload.test.ts +++ b/apps/server/src/assets/AttachmentUpload.test.ts @@ -182,6 +182,33 @@ describe("AttachmentUpload", () => { }).pipe(Effect.provide(testLayer)), ); + it.effect("streams a 100 MB ZIP upload to disk", () => + Effect.gen(function* () { + const config = yield* ServerConfig.ServerConfig; + const issued = yield* issueAttachmentUploadUrl({ + type: "file", + name: "archive.zip", + mimeType: "application/zip", + sizeBytes: 100_000_000, + }); + const token = issued.relativeUrl.slice(ATTACHMENT_UPLOAD_ROUTE_PREFIX.length + 1); + const claims = yield* validateAttachmentUploadToken(token); + if (!claims) throw new Error("Expected valid upload claims."); + const chunk = new Uint8Array(1_000_000); + expect( + yield* storeAttachmentUpload( + claims, + Stream.fromIterable(Array.from({ length: 100 }, () => chunk)), + ), + ).toEqual({ ok: true }); + expect( + NodeFS.statSync(NodePath.join(config.attachmentsDir, `${issued.attachmentId}.zip`)).size, + ).toBe(100_000_000); + yield* deletePendingAttachment(issued.attachmentId); + expect(NodeFS.readdirSync(config.attachmentsDir)).toEqual([]); + }).pipe(Effect.provide(testLayer)), + ); + it.effect("removes partial streamed uploads that exceed their signed size", () => Effect.gen(function* () { const config = yield* ServerConfig.ServerConfig; diff --git a/apps/server/src/environment/ServerEnvironment.test.ts b/apps/server/src/environment/ServerEnvironment.test.ts index f02e8e0ec7e3..f8b662aa173f 100644 --- a/apps/server/src/environment/ServerEnvironment.test.ts +++ b/apps/server/src/environment/ServerEnvironment.test.ts @@ -171,7 +171,7 @@ it.layer(NodeServices.layer)("ServerEnvironmentLive", (it) => { expect(second.capabilities.repositoryIdentity).toBe(true); expect(second.capabilities.connectionProbe).toBe(true); expect(second.capabilities.attachmentUploads).toBe(true); - expect(second.capabilities.fileAttachments).toEqual({ maxUploadBytes: 50 * 1024 * 1024 }); + expect(second.capabilities.fileAttachments).toEqual({ maxUploadBytes: 100_000_000 }); expect(second.capabilities.pullRequests).toBe(true); expect(second.capabilities.requiredWorktreeBootstrap).toBe(true); expect(second.capabilities.usagePriceOverrides).toBe(true); diff --git a/apps/server/src/mcp/toolkits/preview/tools.ts b/apps/server/src/mcp/toolkits/preview/tools.ts index 1c790e8b643a..01b098d82efd 100644 --- a/apps/server/src/mcp/toolkits/preview/tools.ts +++ b/apps/server/src/mcp/toolkits/preview/tools.ts @@ -233,7 +233,7 @@ const PreviewRecordingStartTool = safeBrowserTool( const PreviewRecordingStopTool = safeBrowserTool( Tool.make("preview_recording_stop", { description: - "Stop recording the collaborative browser tab selected by tabId, or this agent session's current tab when omitted, and transfer the compressed recording once (up to 50 MiB) to an evidence file readable in this agent's environment. Returns its environment-local path after transfer succeeds.", + "Stop recording the collaborative browser tab selected by tabId, or this agent session's current tab when omitted, and transfer the compressed recording once (up to 100 MB) to an evidence file readable in this agent's environment. Returns its environment-local path after transfer succeeds.", parameters: PreviewAutomationTabTargetInput, success: Schema.Struct({ ...PreviewAutomationRecordingArtifact.fields, ...presentationFields }), failure: PreviewAutomationError, diff --git a/apps/server/src/orchestration-v2/AttachmentPrompt.test.ts b/apps/server/src/orchestration-v2/AttachmentPrompt.test.ts index 03c35bd4ef9b..5c88ba6989a6 100644 --- a/apps/server/src/orchestration-v2/AttachmentPrompt.test.ts +++ b/apps/server/src/orchestration-v2/AttachmentPrompt.test.ts @@ -39,6 +39,24 @@ describe("provider attachment prompts", () => { ); }); + it("passes a 100 MB ZIP by path", () => { + const attachment = ChatFileAttachment.make({ + ...document, + id: ChatAttachmentId.make("file-archive"), + name: "archive.zip", + mimeType: "application/zip", + sizeBytes: 100_000_000, + }); + assert.equal( + providerMessageTextWithAttachmentPaths({ + text: "", + attachments: [attachment], + attachmentsDir: "/attachments", + }), + '[Attached file "archive.zip" is saved at: /attachments/file-archive.zip]', + ); + }); + it("frames captured-window context as escaped untrusted JSON", () => { const captured = ChatImageAttachment.make({ ...image, diff --git a/apps/server/src/provider/acp/AntigravityAcpSupport.ts b/apps/server/src/provider/acp/AntigravityAcpSupport.ts index 264ff09e4103..428d2997342e 100644 --- a/apps/server/src/provider/acp/AntigravityAcpSupport.ts +++ b/apps/server/src/provider/acp/AntigravityAcpSupport.ts @@ -1,7 +1,6 @@ import { ANTIGRAVITY_DEFAULT_MODEL, type AntigravityAuthMethod, - PROVIDER_SEND_TURN_MAX_FILE_BYTES, PROVIDER_SEND_TURN_MAX_IMAGE_BYTES, type ProviderSendTurnInput, type RuntimeMode, @@ -246,7 +245,7 @@ const TEXT_FILE_EXTENSIONS = new Set([ ".conf", ]); const ANTIGRAVITY_MAX_TEXT_ATTACHMENT_BYTES = 1024 * 1024; -const MAX_TOTAL_ATTACHMENT_BYTES = PROVIDER_SEND_TURN_MAX_FILE_BYTES; +const MAX_TOTAL_ATTACHMENT_BYTES = 50 * 1024 * 1024; /** * Sends supported uploads as native ACP content. Other files, and native diff --git a/apps/server/src/provider/opencodeRuntime.cliParsers.test.ts b/apps/server/src/provider/opencodeRuntime.cliParsers.test.ts index c66a2f36a207..39df72f67c81 100644 --- a/apps/server/src/provider/opencodeRuntime.cliParsers.test.ts +++ b/apps/server/src/provider/opencodeRuntime.cliParsers.test.ts @@ -302,13 +302,14 @@ describe("toOpenCodeFileParts", () => { attachment("image/png"), // A ZIP file part makes OpenCode's Anthropic path throw before the // turn starts; it must ride only as the prompt's file path line. - attachment("application/zip"), + attachment("application/zip", 100_000_000), attachment("application/octet-stream"), // Image formats the model APIs reject stay on the fallback path too. attachment("image/bmp"), attachment("image/svg+xml"), // Over the direct-attachment limit: path fallback even for a PDF. attachment("application/pdf", 21 * 1024 * 1024), + attachment("application/pdf", 100_000_000), ], resolveAttachmentPath: () => "/tmp/attachment", }); diff --git a/docs/user/composer.md b/docs/user/composer.md index 8cb8d7805ac9..b621447374a8 100644 --- a/docs/user/composer.md +++ b/docs/user/composer.md @@ -16,10 +16,10 @@ to keep a large paste editable in the composer instead. Attach up to 100 files per message. Each image can be up to 10 MiB, with at most 80 MiB of images in one message. Other files, including videos, can be up to -50 MiB each, subject to the environment's upload support and limit. The agent -receives them on the environment's machine. Provider and model limits still -apply, including images already in the conversation. A video attachment gives -the agent a file path; it does not enable native video input. +100 MB (100,000,000 bytes) each, subject to the environment's upload support and +limit. The agent receives them on the environment's machine. Provider and model +limits still apply, including images already in the conversation. A video +attachment gives the agent a file path; it does not enable native video input. Uploads begin when you add an attachment. All uploads must finish before the message can send. Retry or remove a failed upload. On web and desktop, reloading diff --git a/packages/client-runtime/src/state/attachments.test.ts b/packages/client-runtime/src/state/attachments.test.ts index f09c47362241..8a99082904f5 100644 --- a/packages/client-runtime/src/state/attachments.test.ts +++ b/packages/client-runtime/src/state/attachments.test.ts @@ -195,6 +195,9 @@ describe("file attachment limits", () => { expect(fileAttachmentTooLargeMessage("one.bin", 1024 * 1024)).toBe( "'one.bin' exceeds the 1 MB attachment limit.", ); + expect(fileAttachmentTooLargeMessage("archive.zip", 100_000_000)).toBe( + "'archive.zip' exceeds the 100 MB attachment limit.", + ); expect(fileAttachmentTooLargeMessage("big.zip", 50 * 1024 * 1024)).toBe( "'big.zip' exceeds the 50 MB attachment limit.", ); diff --git a/packages/client-runtime/src/state/attachments.ts b/packages/client-runtime/src/state/attachments.ts index 0dc457e2cade..d25ca26aa10a 100644 --- a/packages/client-runtime/src/state/attachments.ts +++ b/packages/client-runtime/src/state/attachments.ts @@ -227,10 +227,12 @@ export function formatAttachmentSize(sizeBytes: number): string { /** User-facing rejection for a file over the effective upload limit. */ export function fileAttachmentTooLargeMessage(name: string, maxUploadBytes: number): string { const maxUploadSize = - maxUploadBytes >= 1024 * 1024 && maxUploadBytes % (1024 * 1024) === 0 - ? `${maxUploadBytes / (1024 * 1024)} MB` - : maxUploadBytes >= 1024 && maxUploadBytes % 1024 === 0 - ? `${maxUploadBytes / 1024} KB` - : `${maxUploadBytes} ${maxUploadBytes === 1 ? "byte" : "bytes"}`; + maxUploadBytes >= 1_000_000 && maxUploadBytes % 1_000_000 === 0 + ? `${maxUploadBytes / 1_000_000} MB` + : maxUploadBytes >= 1024 * 1024 && maxUploadBytes % (1024 * 1024) === 0 + ? `${maxUploadBytes / (1024 * 1024)} MB` + : maxUploadBytes >= 1024 && maxUploadBytes % 1024 === 0 + ? `${maxUploadBytes / 1024} KB` + : `${maxUploadBytes} ${maxUploadBytes === 1 ? "byte" : "bytes"}`; return `'${name}' exceeds the ${maxUploadSize} attachment limit.`; } diff --git a/packages/contracts/src/assets.test.ts b/packages/contracts/src/assets.test.ts index 9e4ba486b48a..de9f80f50c86 100644 --- a/packages/contracts/src/assets.test.ts +++ b/packages/contracts/src/assets.test.ts @@ -43,6 +43,20 @@ describe("AttachmentCreateUploadUrlInput", () => { ).toBe(true); }); + it.each([100_000_000, 100_000_001])( + "enforces the 100 MB file boundary at %s bytes", + (sizeBytes) => { + expect( + isUploadInput({ + type: "file", + name: "archive.zip", + mimeType: "application/zip", + sizeBytes, + }), + ).toBe(sizeBytes <= 100_000_000); + }, + ); + it("rejects empty and oversized uploads", () => { expect(isUploadInput({ ...uploadInput, sizeBytes: 0 })).toBe(false); expect( diff --git a/packages/contracts/src/chatAttachment.ts b/packages/contracts/src/chatAttachment.ts index 48769c1a4728..a98223ccc4de 100644 --- a/packages/contracts/src/chatAttachment.ts +++ b/packages/contracts/src/chatAttachment.ts @@ -14,7 +14,7 @@ export const PROVIDER_SEND_TURN_MAX_INPUT_CHARS = 120_000; export const PROVIDER_SEND_TURN_MAX_ATTACHMENTS = 100; export const PROVIDER_SEND_TURN_MAX_IMAGE_BYTES = 10 * 1024 * 1024; const PROVIDER_SEND_TURN_MAX_TOTAL_IMAGE_BYTES = 80 * 1024 * 1024; -export const PROVIDER_SEND_TURN_MAX_FILE_BYTES = 50 * 1024 * 1024; +export const PROVIDER_SEND_TURN_MAX_FILE_BYTES = 100_000_000; export const PROVIDER_SEND_TURN_SUPPORTED_IMAGE_MIME_TYPES = [ "image/gif", "image/jpeg", diff --git a/packages/contracts/src/previewAutomation.ts b/packages/contracts/src/previewAutomation.ts index f87739670f75..753335cf151c 100644 --- a/packages/contracts/src/previewAutomation.ts +++ b/packages/contracts/src/previewAutomation.ts @@ -909,7 +909,7 @@ export class PreviewAutomationRecordingTooLargeError extends Schema.TaggedError< { threadId: ThreadId, cause: Schema.optional(Schema.Defect()) }, ) { override get message(): string { - return "The recording exceeds 50 MiB. The saved copy remains on the desktop."; + return "The recording exceeds 100 MB. The saved copy remains on the desktop."; } }