Skip to content

Commit 5ecc178

Browse files
committed
fix(copilot): clamp the legacy int4 size when materializing a chat upload
`materialize_file(operation: 'save')` wrote the HEADed object size straight into `workspace_files.size`, which is still `integer NOT NULL`. Since the `size_bytes` widening (0289), a mothership chat attachment may be up to MAX_WORKSPACE_FILE_SIZE (5 GiB): `upload-session/service.ts` gives `mothership_attachment` that ceiling, and `finalizers.ts` already dual-writes the row as `size = 2147483647, size_bytes = <exact>`. Saving such an upload then re-read the true size from `headObject` and issued `SET size = 3221225472` against int4. Postgres raises 22003; the retry filter matches only 23505, so it rethrows, the transaction rolls back and the tool returns `success: false` with no way for the user to complete the save. No corruption — int4 overflow errors, it never truncates — but the file can never be saved. Every other `workspace_files` size writer already pairs `toLegacyWorkspaceFileSize(bytes)` with `sizeBytes: bytes` (metadata.ts x4, workspace-file-manager.ts:243/1706, finalizers.ts:367). This call site was simply missed when the widening landed; the fix converges it with the other six rather than inventing a third shape. Storage accounting keeps using the exact `verifiedSize`, so quota and usage are unaffected. The size source itself also had to widen. `head?.size ?? row.size` fell back to the clamped int4 column, and since this change now writes `sizeBytes` too, that fallback would overwrite an exact `size_bytes` with the clamp — the object is gone, so nothing could recover it, and the row would look internally consistent afterwards. The fallback is live whenever `hasCloudStorage()` is false, since the early return at the HEAD miss is cloud-only. Reading `row.sizeBytes ?? row.size` is the same coalescing shape the readers already use (workspace-file-manager.ts:227, finalizers.ts:399, metadata.ts:46), and the row comes from a full `select()` so the column is present. The clamp is derived once next to `verifiedSize` rather than inline in the update because the value is loop-invariant. Two sibling writers were examined and deliberately left alone. `copy-files.ts` reads `task.size` out of the int4 column itself, so it is arithmetically incapable of overflow, and its missing `sizeBytes` is unreachable behind the 100 MB fork download cap. `workspace-file-manager.ts:963` takes a caller-supplied size, but its insert branch needs an orphaned storage object with no `workspace_files` row, and converting loose external input from a DB error into a JS throw deserves its own review rather than a release patch; it is the next instance of this bug and should be filed as a follow-up. Both new tests were proven red against the unfixed code: the clamp test fails with "expected 3221225472 to be 2147483647", the fallback test with "expected undefined to be 3221225472".
1 parent b5d9e93 commit 5ecc178

2 files changed

Lines changed: 64 additions & 2 deletions

File tree

apps/sim/lib/copilot/tools/handlers/materialize-file.test.ts

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -155,6 +155,9 @@ const STORAGE_CONTEXT = {
155155
customStorageLimitGB: null,
156156
}
157157

158+
const POSTGRES_INT4_MAX = 2_147_483_647
159+
const OVERSIZED_BYTES = 3 * 1024 * 1024 * 1024
160+
158161
const mothershipRow = {
159162
id: 'file-1',
160163
key: 'mothership/file-1',
@@ -339,6 +342,58 @@ describe('executeMaterializeFile - save storage transition', () => {
339342
)
340343
})
341344

345+
it('writes an int4-representable legacy size and the exact byte count above the int4 ceiling', async () => {
346+
// A row above the int4 ceiling must not be written raw to `size`: Postgres
347+
// raises 22003 and the save becomes unrecoverable.
348+
mockHeadObject.mockResolvedValue({ size: OVERSIZED_BYTES, contentType: 'text/plain' })
349+
350+
const result = await executeMaterializeFile(
351+
{ fileNames: ['report.txt'], operation: 'save' },
352+
context
353+
)
354+
355+
expect(result.success).toBe(true)
356+
const [updateSet] = dbChainMockFns.set.mock.calls.at(-1) as [Record<string, unknown>]
357+
expect(updateSet.size).toBe(POSTGRES_INT4_MAX)
358+
expect(updateSet.sizeBytes).toBe(OVERSIZED_BYTES)
359+
expect(mockCheckStorageQuotaForBillingContext).toHaveBeenCalledWith(
360+
STORAGE_CONTEXT,
361+
OVERSIZED_BYTES
362+
)
363+
expect(mockIncrementStorageUsageForBillingContextInTx).toHaveBeenCalledWith(
364+
expect.anything(),
365+
STORAGE_CONTEXT,
366+
OVERSIZED_BYTES
367+
)
368+
})
369+
370+
it('falls back to the exact stored byte count, not the clamped legacy size', async () => {
371+
// Without cloud storage a missing object does not short-circuit, so the row is
372+
// the only size source — and its `size` is already clamped.
373+
mockHeadObject.mockResolvedValue(null)
374+
mockHasCloudStorage.mockReturnValue(false)
375+
mockFindUpload.mockResolvedValue({
376+
...mothershipRow,
377+
size: POSTGRES_INT4_MAX,
378+
sizeBytes: OVERSIZED_BYTES,
379+
})
380+
381+
const result = await executeMaterializeFile(
382+
{ fileNames: ['report.txt'], operation: 'save' },
383+
context
384+
)
385+
386+
expect(result.success).toBe(true)
387+
const [updateSet] = dbChainMockFns.set.mock.calls.at(-1) as [Record<string, unknown>]
388+
expect(updateSet.sizeBytes).toBe(OVERSIZED_BYTES)
389+
expect(updateSet.size).toBe(POSTGRES_INT4_MAX)
390+
expect(mockIncrementStorageUsageForBillingContextInTx).toHaveBeenCalledWith(
391+
expect.anything(),
392+
STORAGE_CONTEXT,
393+
OVERSIZED_BYTES
394+
)
395+
})
396+
342397
it('materializes with an available root-level copy name', async () => {
343398
mockFindUpload.mockResolvedValueOnce({
344399
...mothershipRow,

apps/sim/lib/copilot/tools/handlers/materialize-file.ts

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ import {
3636
} from '@/lib/uploads/contexts/workspace/workspace-file-manager'
3737
import { getBoundWorkspaceFileSecretProvenance } from '@/lib/uploads/contexts/workspace/workspace-file-secret-provenance'
3838
import { hasCloudStorage, headObject } from '@/lib/uploads/core/storage-service'
39+
import { toLegacyWorkspaceFileSize } from '@/lib/uploads/shared/types'
3940
import { isArchiveFileName } from '@/lib/uploads/utils/file-utils'
4041
import { parseWorkflowJson } from '@/lib/workflows/operations/import-export'
4142
import { saveWorkflowToNormalizedTables } from '@/lib/workflows/persistence/utils'
@@ -109,7 +110,12 @@ async function executeSave(
109110
if (!head && hasCloudStorage()) {
110111
return { success: false, error: `Upload object not found: "${fileName}".` }
111112
}
112-
const verifiedSize = head?.size ?? row.size
113+
/**
114+
* The true byte count can exceed the legacy int4 `size` column, so read the exact
115+
* `sizeBytes` first and clamp back down for the legacy projection.
116+
*/
117+
const verifiedSize = head?.size ?? row.sizeBytes ?? row.size
118+
const legacySize = toLegacyWorkspaceFileSize(verifiedSize)
113119
const billingContext = await resolveStorageBillingContext(workspaceId)
114120
const quotaCheck = await checkStorageQuotaForBillingContext(billingContext, verifiedSize)
115121
if (!quotaCheck.allowed) {
@@ -145,7 +151,8 @@ async function executeSave(
145151
messageId: null,
146152
originalName: materializedName,
147153
displayName: materializedName,
148-
size: verifiedSize,
154+
size: legacySize,
155+
sizeBytes: verifiedSize,
149156
})
150157
.where(
151158
and(

0 commit comments

Comments
 (0)