Skip to content
Closed
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
34 changes: 34 additions & 0 deletions apps/server/src/provider/Layers/ProviderService.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -986,6 +986,40 @@ it.effect("ProviderServiceLive rejects new sessions for disabled custom instance
}).pipe(Effect.provide(NodeServices.layer)),
);

for (const name of ["codex", "claudeAgent", "cursor", "grok", "opencode", "antigravity"]) {
const driver = ProviderDriverKind.make(name);
const instanceId = ProviderInstanceId.make(name);
const adapter = makeFakeCodexAdapter(driver);
makeProviderServiceLayer({
registry: makeStaticInstanceRegistry([[instanceId, adapter.adapter]]),
}).layer(`${name} file attachment limits`, (it) => {
it.effect("passes a 100 MB ZIP by path", () =>
Effect.gen(function* () {
const provider = yield* ProviderService.ProviderService;
const threadId = asThreadId(`zip-${name}`);
yield* provider.startSession(threadId, {
provider: driver,
providerInstanceId: instanceId,
threadId,
runtimeMode: "full-access",
});
const attachment = {
type: "file" as const,
id: "thread-attach-12345678-1234-1234-1234-123456789abc-zip",
name: "archive.zip",
mimeType: "application/zip",
sizeBytes: 100_000_000,
};
yield* provider.sendTurn({ threadId, attachments: [attachment] });
const sent = adapter.sendTurn.mock.calls[0]?.[0];
assert.include(sent?.input ?? "", '[Attached file "archive.zip" is saved at: ');
assert.include(sent?.input ?? "", `${attachment.id}.zip]`);
assert.deepEqual(sent?.attachments, [attachment]);
}),
);
});
}

const routing = makeProviderServiceLayer();

const customCompactionDriver = ProviderDriverKind.make("custom-compaction-provider");
Expand Down
11 changes: 5 additions & 6 deletions apps/server/src/provider/acp/AntigravityAcpSupport.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@ import * as NodeServices from "@effect/platform-node/NodeServices";
import { describe, expect, it } from "@effect/vitest";
import {
ANTIGRAVITY_DEFAULT_MODEL,
PROVIDER_SEND_TURN_MAX_FILE_BYTES,
PROVIDER_SEND_TURN_MAX_IMAGE_BYTES,
type ChatAttachment,
type RuntimeMode,
Expand Down Expand Up @@ -403,7 +402,7 @@ it.layer(NodeServices.layer)("buildAntigravityPrompt", (it) => {
it.effect.each([
{ attachment: textAttachment, bytes: ANTIGRAVITY_MAX_TEXT_ATTACHMENT_BYTES + 1 },
{ attachment: imageAttachment, bytes: PROVIDER_SEND_TURN_MAX_IMAGE_BYTES + 1 },
{ attachment: pdfAttachment, bytes: PROVIDER_SEND_TURN_MAX_FILE_BYTES + 1 },
{ attachment: pdfAttachment, bytes: 50 * 1024 * 1024 + 1 },
])(
"rejects oversized $attachment.name using file size instead of upload metadata",
({ attachment, bytes }) =>
Expand Down Expand Up @@ -435,8 +434,8 @@ it.layer(NodeServices.layer)("buildAntigravityPrompt", (it) => {
};
const first = yield* fixture.write(pdfAttachment, "");
const second = yield* fixture.write(secondAttachment, "");
yield* fixture.fs.truncate(first.filePath, PROVIDER_SEND_TURN_MAX_FILE_BYTES / 2);
yield* fixture.fs.truncate(second.filePath, PROVIDER_SEND_TURN_MAX_FILE_BYTES / 2);
yield* fixture.fs.truncate(first.filePath, (50 * 1024 * 1024) / 2);
yield* fixture.fs.truncate(second.filePath, (50 * 1024 * 1024) / 2);
const input = {
input: undefined,
attachments: [pdfAttachment, secondAttachment],
Expand All @@ -448,7 +447,7 @@ it.layer(NodeServices.layer)("buildAntigravityPrompt", (it) => {
{ type: "resource_link", uri: second.uri, name: "second.pdf", mimeType: "application/pdf" },
]);

yield* fixture.fs.truncate(second.filePath, PROVIDER_SEND_TURN_MAX_FILE_BYTES / 2 + 1);
yield* fixture.fs.truncate(second.filePath, (50 * 1024 * 1024) / 2 + 1);
const error = yield* buildAntigravityPrompt(input).pipe(Effect.flip);
expect(error).toMatchObject({
_tag: "AcpRequestError",
Expand All @@ -463,7 +462,7 @@ it.layer(NodeServices.layer)("buildAntigravityPrompt", (it) => {
const fixture = yield* makeAttachmentFixture();
const pdf = yield* fixture.write(pdfAttachment, "");
const text = yield* fixture.write(textAttachment, "a");
yield* fixture.fs.truncate(pdf.filePath, PROVIDER_SEND_TURN_MAX_FILE_BYTES - 1);
yield* fixture.fs.truncate(pdf.filePath, 50 * 1024 * 1024 - 1);
const error = yield* buildAntigravityPrompt({
input: undefined,
attachments: [pdfAttachment, textAttachment],
Expand Down
5 changes: 2 additions & 3 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 @@ -244,7 +243,7 @@ const TEXT_FILE_EXTENSIONS = new Set([
".conf",
]);
export 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;

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Merge trap with #13339: that PR keeps MAX_TOTAL_ATTACHMENT_BYTES = PROVIDER_SEND_TURN_MAX_FILE_BYTES on this line, and the two PRs conflict here. If that side wins, the native budget silently becomes 100 MB, while the error message and the Antigravity user docs still say 50 MiB. Keep this 50 MiB literal whichever PR lands second.


/** Sends uploads as native ACP content instead of workspace path hints. */
export const buildAntigravityPrompt = Effect.fn("buildAntigravityPrompt")(function* (input: {
Expand Down Expand Up @@ -317,7 +316,7 @@ export const buildAntigravityPrompt = Effect.fn("buildAntigravityPrompt")(functi
: audio
? ANTIGRAVITY_MAX_AUDIO_ATTACHMENT_BYTES
: pdf
? PROVIDER_SEND_TURN_MAX_FILE_BYTES
? MAX_TOTAL_ATTACHMENT_BYTES
: ANTIGRAVITY_MAX_TEXT_ATTACHMENT_BYTES;
totalBytes += size;
if (info.type !== "File" || size > limit || totalBytes > MAX_TOTAL_ATTACHMENT_BYTES) {
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
10 changes: 5 additions & 5 deletions docs/user/composer.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,11 +16,11 @@ 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. Antigravity does
not accept video attachments.
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.
Antigravity does not accept video attachments.

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/orchestration.ts
Original file line number Diff line number Diff line change
Expand Up @@ -166,7 +166,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;

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Blocker (see review summary): this constant also bounds ChatFileAttachment.sizeBytes on decode. Every shipped client, and any server downgraded to v0.0.42, still rejects anything over 52,428,800 bytes, so one 75 MB attachment makes the thread undecodable for them. Please drop the read-side bound first, enforce the cap at upload issuance (assets.ts:103), and raise this constant only after the relaxed reader ships.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed. I opened #13429 against main to remove the read-side file-size bound while keeping the 50 MiB upload cap. Its focused decoder test accepts a 75 MB file, rejects zero bytes, and the existing upload-cap test still passes. I added the rollout dependency to this PR description. I will leave this thread open and hold #13261 until that reader change reaches stable desktop and mobile, unless you explicitly accept the version-skew risk.

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 @@ -910,7 +910,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