fix(artifacts): raise desktop sharing limit to 5 MiB

This commit is contained in:
Jinwoo-H
2026-08-31 16:23:15 -04:00
parent aa658d28e3
commit 3ab9ea0253
10 changed files with 120 additions and 25 deletions
@@ -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 () => {
@@ -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<string>()
+47 -3
View File
@@ -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
)
})
})
+12 -4
View File
@@ -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[] = [
@@ -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))
@@ -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(
@@ -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<ArtifactPublishPreparationError>)
})
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'
})
})
})
@@ -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<Artif
if (stat.isDirectory) {
throw new ArtifactPublishPreparationError('unsupported')
}
if (stat.size > 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 })
+1 -1
View File
@@ -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."
+11
View File
@@ -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
}