mirror of
https://github.com/stablyai/orca.git
synced 2026-10-02 08:02:02 +00:00
fix(artifacts): raise desktop sharing limit to 5 MiB
This commit is contained in:
@@ -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>()
|
||||
|
||||
@@ -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
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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(
|
||||
|
||||
+14
-2
@@ -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 })
|
||||
|
||||
@@ -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."
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user