From 3ab9ea0253e3a0bc64fe645792d2a2b7c4708d9d Mon Sep 17 00:00:00 2001 From: Jinwoo-H Date: Sun, 30 Aug 2026 16:09:27 -0400 Subject: [PATCH] fix(artifacts): raise desktop sharing limit to 5 MiB --- .../artifact-create-intent-store.test.ts | 10 ++-- .../artifacts/artifact-create-intent-store.ts | 4 +- .../runtime/rpc/methods/artifacts.test.ts | 50 +++++++++++++++++-- src/main/runtime/rpc/methods/artifacts.ts | 16 ++++-- .../artifacts/artifact-publish-flow.test.ts | 21 ++++++-- .../artifacts/artifact-publish-flow.ts | 11 ++-- .../browser-artifact-upload.test.ts | 16 +++++- .../describe-page/browser-artifact-upload.ts | 4 +- src/renderer/src/i18n/locales/en.json | 2 +- src/shared/artifacts.ts | 11 ++++ 10 files changed, 120 insertions(+), 25 deletions(-) diff --git a/src/main/artifacts/artifact-create-intent-store.test.ts b/src/main/artifacts/artifact-create-intent-store.test.ts index dfcbe003df7..32da4e1a6cd 100644 --- a/src/main/artifacts/artifact-create-intent-store.test.ts +++ b/src/main/artifacts/artifact-create-intent-store.test.ts @@ -3,7 +3,7 @@ import { mkdtemp, readFile, readdir, rm, stat, truncate, writeFile } from 'node: import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, describe, expect, it, vi } from 'vitest' -import { ARTIFACT_CLI_MAX_RPC_BYTES, artifactWriteRequestByteLength } from '../../shared/artifacts' +import { ARTIFACT_MAX_REQUEST_BYTES, artifactWriteRequestByteLength } from '../../shared/artifacts' import { MAX_ARTIFACT_CREATE_INTENT_BYTES, MAX_PENDING_ARTIFACT_CREATES, @@ -270,12 +270,12 @@ describe('artifact create intent store', () => { ).toThrow(/unsupported format/) }) - it('persists a valid artifact request near the RPC limit', async () => { + it('persists a valid artifact request near the recovery limit', async () => { const userDataPath = await createUserDataPath() - const nearLimitBody = { ...body, content: 'x'.repeat(ARTIFACT_CLI_MAX_RPC_BYTES - 200) } + const nearLimitBody = { ...body, content: 'x'.repeat(ARTIFACT_MAX_REQUEST_BYTES - 200) } expect( artifactWriteRequestByteLength({ sourceKey: '/repo/report.html', ...nearLimitBody }) - ).toBeLessThanOrEqual(ARTIFACT_CLI_MAX_RPC_BYTES) + ).toBeLessThanOrEqual(ARTIFACT_MAX_REQUEST_BYTES) expect(() => getOrCreateArtifactCreateIntent( @@ -289,7 +289,7 @@ describe('artifact create intent store', () => { ).not.toThrow() const directory = join(userDataPath, 'profiles', 'local-profile', 'artifact-create-intents') const [fileName] = await readdir(directory) - expect((await stat(join(directory, fileName))).size).toBeGreaterThan(ARTIFACT_CLI_MAX_RPC_BYTES) + expect((await stat(join(directory, fileName))).size).toBeGreaterThan(ARTIFACT_MAX_REQUEST_BYTES) }) it('rejects an oversized recovery record before reading it', async () => { diff --git a/src/main/artifacts/artifact-create-intent-store.ts b/src/main/artifacts/artifact-create-intent-store.ts index d544d04ceec..b463ad4ce10 100644 --- a/src/main/artifacts/artifact-create-intent-store.ts +++ b/src/main/artifacts/artifact-create-intent-store.ts @@ -10,7 +10,7 @@ import { writeFileSync } from 'node:fs' import { join } from 'node:path' -import { ARTIFACT_CLI_MAX_RPC_BYTES } from '../../shared/artifacts' +import { ARTIFACT_MAX_REQUEST_BYTES } from '../../shared/artifacts' import { bestEffortFsyncDirectorySync, fsyncFileSync, @@ -21,7 +21,7 @@ import type { ArtifactWriteBody } from './artifact-cloud-request' import type { ArtifactShareScope } from './artifact-share-record-store' export const MAX_PENDING_ARTIFACT_CREATES = 32 -export const MAX_ARTIFACT_CREATE_INTENT_BYTES = ARTIFACT_CLI_MAX_RPC_BYTES + 128 * 1024 +export const MAX_ARTIFACT_CREATE_INTENT_BYTES = ARTIFACT_MAX_REQUEST_BYTES + 128 * 1024 const MAX_HARDENED_INTENT_DIRECTORIES = 64 const hardenedIntentDirectories = new Set() diff --git a/src/main/runtime/rpc/methods/artifacts.test.ts b/src/main/runtime/rpc/methods/artifacts.test.ts index 2a84cf94d21..478e5b030d5 100644 --- a/src/main/runtime/rpc/methods/artifacts.test.ts +++ b/src/main/runtime/rpc/methods/artifacts.test.ts @@ -1,5 +1,8 @@ import { describe, expect, it } from 'vitest' -import { ARTIFACT_CLI_MAX_RPC_BYTES } from '../../../../shared/artifacts' +import { + ARTIFACT_MAX_CONTENT_BYTES, + ARTIFACT_MAX_REQUEST_BYTES +} from '../../../../shared/artifacts' import { ARTIFACT_METHODS } from './artifacts' const validRequest = { @@ -32,14 +35,55 @@ describe('artifact RPC schemas', () => { const schema = writeSchema('artifacts.publish') expect(schema.safeParse({ ...validRequest, content: '' }).success).toBe(false) expect( - schema.safeParse({ ...validRequest, content: 'x'.repeat(ARTIFACT_CLI_MAX_RPC_BYTES + 1) }) + schema.safeParse({ ...validRequest, content: 'x'.repeat(ARTIFACT_MAX_CONTENT_BYTES + 1) }) .success ).toBe(false) expect( schema.safeParse({ ...validRequest, - content: '"'.repeat(Math.floor(ARTIFACT_CLI_MAX_RPC_BYTES / 2)) + content: '"'.repeat(ARTIFACT_MAX_CONTENT_BYTES + 1) }).success ).toBe(false) }) + + it('accepts a 5 MiB UTF-8 artifact at the content boundary', () => { + expect( + writeSchema('artifacts.publish').safeParse({ + ...validRequest, + content: 'a'.repeat(ARTIFACT_MAX_CONTENT_BYTES) + }).success + ).toBe(true) + }) + + it('measures the content boundary in UTF-8 bytes', () => { + const exact = `${'€'.repeat(Math.floor(ARTIFACT_MAX_CONTENT_BYTES / 3))}aa` + const oversized = `${exact}€` + expect(new TextEncoder().encode(exact).byteLength).toBe(ARTIFACT_MAX_CONTENT_BYTES) + expect(new TextEncoder().encode(oversized).byteLength).toBeGreaterThan( + ARTIFACT_MAX_CONTENT_BYTES + ) + expect( + writeSchema('artifacts.publish').safeParse({ ...validRequest, content: exact }).success + ).toBe(true) + expect( + writeSchema('artifacts.publish').safeParse({ ...validRequest, content: oversized }).success + ).toBe(false) + }) + + it('allows JSON escaping within the bounded 5 MiB content request', () => { + expect( + writeSchema('artifacts.publish').safeParse({ + ...validRequest, + content: '"'.repeat(ARTIFACT_MAX_CONTENT_BYTES) + }).success + ).toBe(true) + }) + + it('rejects an escaped envelope beyond the request budget', () => { + const content = '\u0000'.repeat(Math.ceil(ARTIFACT_MAX_REQUEST_BYTES / 6)) + expect(new TextEncoder().encode(content).byteLength).toBeLessThan(ARTIFACT_MAX_CONTENT_BYTES) + expect(writeSchema('artifacts.publish').safeParse({ ...validRequest, content }).success).toBe( + false + ) + }) }) diff --git a/src/main/runtime/rpc/methods/artifacts.ts b/src/main/runtime/rpc/methods/artifacts.ts index 79d99ba7dd0..ace49a53ed3 100644 --- a/src/main/runtime/rpc/methods/artifacts.ts +++ b/src/main/runtime/rpc/methods/artifacts.ts @@ -1,6 +1,8 @@ import { z } from 'zod' import { - ARTIFACT_CLI_MAX_RPC_BYTES, + ARTIFACT_MAX_CONTENT_BYTES, + ARTIFACT_MAX_REQUEST_BYTES, + artifactContentByteLength, artifactWriteRequestByteLength } from '../../../../shared/artifacts' import { defineMethod, type RpcAnyMethod } from '../core' @@ -23,14 +25,20 @@ const SourceRequest = z.object({ const WriteRequest = z .object({ sourceKey: z.string().min(1).max(32_768), - content: z.string().min(1).max(ARTIFACT_CLI_MAX_RPC_BYTES), + content: z + .string() + .min(1) + .max(ARTIFACT_MAX_CONTENT_BYTES) + .refine((content) => artifactContentByteLength(content) <= ARTIFACT_MAX_CONTENT_BYTES, { + message: 'Artifact content exceeds the 5 MiB limit.' + }), contentType: z.enum(['text/html', 'text/markdown']), fileName: z.string().min(1).max(512), title: z.string().max(512).optional(), ...CloudOptions }) - .refine((request) => artifactWriteRequestByteLength(request) <= ARTIFACT_CLI_MAX_RPC_BYTES, { - message: 'Artifact request exceeds the local RPC size limit.' + .refine((request) => artifactWriteRequestByteLength(request) <= ARTIFACT_MAX_REQUEST_BYTES, { + message: 'Artifact request exceeds the supported size.' }) export const ARTIFACT_METHODS: readonly RpcAnyMethod[] = [ diff --git a/src/renderer/src/components/artifacts/artifact-publish-flow.test.ts b/src/renderer/src/components/artifacts/artifact-publish-flow.test.ts index 36ec7d0f244..977a6d64373 100644 --- a/src/renderer/src/components/artifacts/artifact-publish-flow.test.ts +++ b/src/renderer/src/components/artifacts/artifact-publish-flow.test.ts @@ -1,5 +1,5 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' -import { ARTIFACT_CLI_MAX_RPC_BYTES } from '../../../../shared/artifacts' +import { ARTIFACT_MAX_CONTENT_BYTES } from '../../../../shared/artifacts' import { publishArtifactFromSurface } from './artifact-publish-flow' const mocks = vi.hoisted(() => ({ @@ -91,16 +91,31 @@ describe('artifact publish flow', () => { it('rejects an oversized request before RPC', async () => { const createRequest = vi.fn().mockResolvedValue({ ...request, - content: '"'.repeat(Math.floor(ARTIFACT_CLI_MAX_RPC_BYTES / 2)) + content: '"'.repeat(ARTIFACT_MAX_CONTENT_BYTES + 1) }) await expect(publishArtifactFromSurface(createRequest)).resolves.toBeNull() expect(mocks.callRuntimeRpc).not.toHaveBeenCalled() expect(mocks.toastError).toHaveBeenCalledWith('Could not share artifact', { - description: 'Artifacts shared from Orca must be smaller than 800 KB.' + description: 'Artifacts shared from Orca must be 5 MB or smaller.' }) }) + it('publishes content at the 5 MiB boundary', async () => { + mocks.callRuntimeRpc.mockResolvedValue({ status: 'ok', value: published }) + const createRequest = vi.fn().mockResolvedValue({ + ...request, + content: 'a'.repeat(ARTIFACT_MAX_CONTENT_BYTES) + }) + + await expect(publishArtifactFromSurface(createRequest)).resolves.toBe(published) + expect(mocks.callRuntimeRpc).toHaveBeenCalledWith( + { kind: 'local' }, + 'artifacts.publish', + expect.objectContaining({ content: 'a'.repeat(ARTIFACT_MAX_CONTENT_BYTES) }) + ) + }) + it('shows confirmation without putting the public link in the toast', async () => { mocks.callRuntimeRpc.mockResolvedValue({ status: 'ok', value: published }) await publishArtifactFromSurface(() => Promise.resolve(request)) diff --git a/src/renderer/src/components/artifacts/artifact-publish-flow.ts b/src/renderer/src/components/artifacts/artifact-publish-flow.ts index 715bb036b09..46785ee870e 100644 --- a/src/renderer/src/components/artifacts/artifact-publish-flow.ts +++ b/src/renderer/src/components/artifacts/artifact-publish-flow.ts @@ -5,7 +5,9 @@ import type { ArtifactWriteRequest } from '../../../../shared/artifacts' import { - ARTIFACT_CLI_MAX_RPC_BYTES, + ARTIFACT_MAX_CONTENT_BYTES, + ARTIFACT_MAX_REQUEST_BYTES, + artifactContentByteLength, artifactWriteRequestByteLength } from '../../../../shared/artifacts' import { translate } from '@/i18n/i18n' @@ -33,7 +35,10 @@ export function validateArtifactPublishRequest( if (!request.content) { throw new ArtifactPublishPreparationError('empty') } - if (artifactWriteRequestByteLength(request) > ARTIFACT_CLI_MAX_RPC_BYTES) { + if ( + artifactContentByteLength(request.content) > ARTIFACT_MAX_CONTENT_BYTES || + artifactWriteRequestByteLength(request) > ARTIFACT_MAX_REQUEST_BYTES + ) { throw new ArtifactPublishPreparationError('too-large') } return request @@ -123,7 +128,7 @@ function artifactPreparationErrorDescription(code: ArtifactPublishPreparationErr case 'too-large': return translate( 'auto.components.artifacts.artifact-publish-flow.6112db5a1c', - 'Artifacts shared from Orca must be smaller than 800 KB.' + 'Artifacts shared from Orca must be 5 MB or smaller.' ) case 'unreadable': return translate( diff --git a/src/renderer/src/components/browser-pane/describe-page/browser-artifact-upload.test.ts b/src/renderer/src/components/browser-pane/describe-page/browser-artifact-upload.test.ts index 04db05e0924..a5708a04a4a 100644 --- a/src/renderer/src/components/browser-pane/describe-page/browser-artifact-upload.test.ts +++ b/src/renderer/src/components/browser-pane/describe-page/browser-artifact-upload.test.ts @@ -1,7 +1,7 @@ // @vitest-environment happy-dom import { beforeEach, describe, expect, it, vi } from 'vitest' -import { ARTIFACT_CLI_MAX_RPC_BYTES } from '../../../../../shared/artifacts' +import { ARTIFACT_MAX_CONTENT_BYTES } from '../../../../../shared/artifacts' import type { ArtifactPublishPreparationError } from '@/components/artifacts/artifact-publish-flow' import { getShareableBrowserArtifactFile, @@ -49,7 +49,7 @@ describe('browser artifact upload', () => { it('rejects oversized and unreadable files before upload', async () => { stat.mockResolvedValueOnce({ - size: ARTIFACT_CLI_MAX_RPC_BYTES + 1, + size: ARTIFACT_MAX_CONTENT_BYTES + 1, isDirectory: false, mtime: 1 }) @@ -63,4 +63,16 @@ describe('browser artifact upload', () => { code: 'unreadable' } satisfies Partial) }) + + it('accepts a file whose stat is exactly at the 5 MiB boundary', async () => { + stat.mockResolvedValueOnce({ + size: ARTIFACT_MAX_CONTENT_BYTES, + isDirectory: false, + mtime: 1 + }) + + await expect(readBrowserHtmlArtifactRequest('file:///tmp/exact.html')).resolves.toMatchObject({ + sourceKey: '/tmp/exact.html' + }) + }) }) diff --git a/src/renderer/src/components/browser-pane/describe-page/browser-artifact-upload.ts b/src/renderer/src/components/browser-pane/describe-page/browser-artifact-upload.ts index ad9103720a2..8288e2d306a 100644 --- a/src/renderer/src/components/browser-pane/describe-page/browser-artifact-upload.ts +++ b/src/renderer/src/components/browser-pane/describe-page/browser-artifact-upload.ts @@ -1,5 +1,5 @@ import type { ArtifactWriteRequest } from '../../../../../shared/artifacts' -import { ARTIFACT_CLI_MAX_RPC_BYTES } from '../../../../../shared/artifacts' +import { ARTIFACT_MAX_CONTENT_BYTES } from '../../../../../shared/artifacts' import { getRuntimePathBasename } from '../../../../../shared/cross-platform-path' import { ArtifactPublishPreparationError } from '@/components/artifacts/artifact-publish-flow' @@ -48,7 +48,7 @@ export async function readBrowserHtmlArtifactRequest(url: string): Promise ARTIFACT_CLI_MAX_RPC_BYTES) { + if (stat.size > ARTIFACT_MAX_CONTENT_BYTES) { throw new ArtifactPublishPreparationError('too-large') } const result = await window.api.fs.readFile({ filePath: file.filePath }) diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index 94d2857da25..82f8bb6185e 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -16415,7 +16415,7 @@ "2fc727c831": "Artifact updated", "5cb4f5ec36": "Copy link", "fbb5018602": "This file is empty.", - "6112db5a1c": "Artifacts shared from Orca must be smaller than 800 KB.", + "6112db5a1c": "Artifacts shared from Orca must be 5 MB or smaller.", "e2ed5acd8c": "Orca couldn't read this file. Open it from a workspace and try again.", "6d475e9b25": "Only local HTML and Markdown files can be shared as artifacts.", "29a406be09": "Artifacts must contain text." diff --git a/src/shared/artifacts.ts b/src/shared/artifacts.ts index 02e1f674f9d..3a65dbfe7ae 100644 --- a/src/shared/artifacts.ts +++ b/src/shared/artifacts.ts @@ -1,5 +1,16 @@ +/** Maximum UTF-8 bytes accepted for a manually shared artifact. */ +export const ARTIFACT_MAX_CONTENT_BYTES = 5 * 1024 * 1024 + +/** Legacy CLI/SSH envelope cap; those transports still have ~1 MiB control frames. */ export const ARTIFACT_CLI_MAX_RPC_BYTES = 800 * 1024 +/** Allows JSON escaping while staying below the cloud API's 11 MiB body budget. */ +export const ARTIFACT_MAX_REQUEST_BYTES = 11 * 1024 * 1024 + +export function artifactContentByteLength(content: string): number { + return new TextEncoder().encode(content).byteLength +} + export function artifactWriteRequestByteLength(request: ArtifactWriteRequest): number { return new TextEncoder().encode(JSON.stringify(request)).byteLength }