Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
Expand Down
16 changes: 7 additions & 9 deletions apps/mobile/src/lib/composerFiles.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -676,24 +676,22 @@ 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: [
{
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.",
});
});

Expand Down
27 changes: 27 additions & 0 deletions apps/server/src/assets/AttachmentUpload.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
2 changes: 1 addition & 1 deletion apps/server/src/environment/ServerEnvironment.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
2 changes: 1 addition & 1 deletion apps/server/src/mcp/toolkits/preview/tools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
18 changes: 18 additions & 0 deletions apps/server/src/orchestration-v2/AttachmentPrompt.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
3 changes: 1 addition & 2 deletions apps/server/src/provider/acp/AntigravityAcpSupport.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -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
Expand Down
3 changes: 2 additions & 1 deletion apps/server/src/provider/opencodeRuntime.cliParsers.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
});
Expand Down
8 changes: 4 additions & 4 deletions docs/user/composer.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
3 changes: 3 additions & 0 deletions packages/client-runtime/src/state/attachments.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.",
);
Expand Down
12 changes: 7 additions & 5 deletions packages/client-runtime/src/state/attachments.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.`;
}
14 changes: 14 additions & 0 deletions packages/contracts/src/assets.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
2 changes: 1 addition & 1 deletion packages/contracts/src/chatAttachment.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Comment thread
Bil0000 marked this conversation as resolved.
export const PROVIDER_SEND_TURN_SUPPORTED_IMAGE_MIME_TYPES = [
"image/gif",
"image/jpeg",
Expand Down
2 changes: 1 addition & 1 deletion packages/contracts/src/previewAutomation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.";
}
}

Expand Down
Loading