Skip to content

Commit ed2bc08

Browse files
committed
fix(uploads): manual uploads provenance ignore
1 parent e698b10 commit ed2bc08

9 files changed

Lines changed: 121 additions & 74 deletions

File tree

apps/sim/executor/handlers/agent/agent-handler.test.ts

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import {
1818
vi,
1919
} from 'vitest'
2020
import type { AutoRoutingSignals } from '@/lib/model-router/resolve'
21+
import * as userFileBase64 from '@/lib/uploads/utils/user-file-base64.server'
2122
import { getAllBlocks } from '@/blocks'
2223
import { AGENT, BlockType, isMcpTool } from '@/executor/constants'
2324
import { AgentBlockHandler } from '@/executor/handlers/agent/agent-handler'
@@ -469,6 +470,47 @@ describe('AgentBlockHandler', () => {
469470
})
470471
})
471472

473+
it('normalizes the persisted workspace-picker shape before provider execution', async () => {
474+
const key = 'workspace/ws-1/example.png'
475+
const hydrationSpy = vi
476+
.spyOn(userFileBase64, 'hydrateUserFilesWithBase64')
477+
.mockImplementationOnce(async (files) =>
478+
files.map((file) => ({ ...file, base64: 'aW1hZ2U=' }))
479+
)
480+
481+
try {
482+
mockGetProviderFromModel.mockReturnValue('openai')
483+
484+
await handler.execute(mockContext, mockBlock, {
485+
model: 'gpt-4o',
486+
userPrompt: 'Analyze this file',
487+
files: [
488+
{
489+
name: 'example.png',
490+
path: `/api/files/serve/${encodeURIComponent(key)}?context=workspace`,
491+
key,
492+
size: 128,
493+
type: 'image/png',
494+
},
495+
],
496+
apiKey: 'test-api-key',
497+
})
498+
499+
const normalizedFile = hydrationSpy.mock.calls[0][0][0]
500+
expect(normalizedFile).toMatchObject({
501+
id: expect.stringMatching(/^file-\d+$/),
502+
key,
503+
name: 'example.png',
504+
type: 'image/png',
505+
})
506+
expect(mockExecuteProviderRequest.mock.calls[0][1].messages.at(-1)?.files).toEqual([
507+
expect.objectContaining({ key, name: 'example.png', base64: 'aW1hZ2U=' }),
508+
])
509+
} finally {
510+
hydrationSpy.mockRestore()
511+
}
512+
})
513+
472514
it('should reject files for providers without attachment support', async () => {
473515
const inputs = {
474516
model: 'deepseek-chat',

apps/sim/lib/copilot/request/lifecycle/run.test.ts

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -496,14 +496,10 @@ describe('runCopilotLifecycle', () => {
496496
expect(JSON.parse(capturedRequestBody).fileAttachments).toEqual([safe])
497497
})
498498

499-
it('continues without attachments when durable provenance cannot be verified', async () => {
499+
it('rejects when durable attachment provenance cannot be verified', async () => {
500500
mockFilterModelSafeWorkspaceFileAttachments.mockRejectedValueOnce(new Error('db unavailable'))
501-
let capturedRequestBody = ''
502-
mockRunStreamLoop.mockImplementationOnce(async (_url: string, request: RequestInit) => {
503-
capturedRequestBody = String(request.body)
504-
})
505501

506-
await runCopilotLifecycle(
502+
const result = await runCopilotLifecycle(
507503
{
508504
message: 'Continue safely',
509505
fileAttachments: [{ id: 'wf-file', name: 'file.txt', key: 'workspace/ws-1/file.txt' }],
@@ -517,7 +513,11 @@ describe('runCopilotLifecycle', () => {
517513
}
518514
)
519515

520-
expect(JSON.parse(capturedRequestBody)).not.toHaveProperty('fileAttachments')
516+
expect(result).toMatchObject({
517+
success: false,
518+
error: 'Copilot model input could not be safely projected',
519+
})
520+
expect(mockRunStreamLoop).not.toHaveBeenCalled()
521521
})
522522

523523
it.each(['123', 'true'])(

apps/sim/lib/copilot/request/lifecycle/run.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -607,11 +607,11 @@ async function omitUnsafeInitialCopilotAttachments(
607607
try {
608608
safeAttachments = await filterModelSafeWorkspaceFileAttachments(attachments, { workspaceId })
609609
} catch (error) {
610-
logger.warn('Workspace file secret provenance could not be verified; omitting attachments', {
610+
logger.error('Workspace file secret provenance could not be verified', {
611611
attachmentCount: attachments.length,
612612
error: toError(error).message,
613613
})
614-
safeAttachments = []
614+
throw new CopilotModelContentProjectionError()
615615
}
616616

617617
if (safeAttachments.length === attachments.length) continue

apps/sim/lib/uploads/contexts/workspace/track-chat-upload.test.ts

Lines changed: 5 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -141,12 +141,7 @@ describe('trackChatUpload', () => {
141141
displayName: 'image.png',
142142
})
143143
)
144-
expect(mockReplaceWorkspaceFileSecretProvenanceInTx).toHaveBeenCalledWith(
145-
expect.anything(),
146-
'wf_existing',
147-
CONTENT_UPDATED_AT,
148-
{ status: 'exact', entries: [] }
149-
)
144+
expect(mockReplaceWorkspaceFileSecretProvenanceInTx).not.toHaveBeenCalled()
150145
expectNoWorkspaceStorageAccounting()
151146
})
152147

@@ -173,16 +168,11 @@ describe('trackChatUpload', () => {
173168
displayName: 'image.png',
174169
})
175170
)
176-
expect(mockReplaceWorkspaceFileSecretProvenanceInTx).toHaveBeenCalledWith(
177-
expect.anything(),
178-
'wf_inserted',
179-
CONTENT_UPDATED_AT,
180-
{ status: 'exact', entries: [] }
181-
)
171+
expect(mockReplaceWorkspaceFileSecretProvenanceInTx).not.toHaveBeenCalled()
182172
expectNoWorkspaceStorageAccounting()
183173
})
184174

185-
it('fails the chat binding transaction when provenance classification fails', async () => {
175+
it('does not reclassify uploaded bytes while linking chat metadata', async () => {
186176
queueOwnershipLookup([existingRow()])
187177
dbChainMockFns.returning.mockResolvedValueOnce([
188178
{ id: 'wf_existing', contentUpdatedAt: CONTENT_UPDATED_AT },
@@ -193,9 +183,10 @@ describe('trackChatUpload', () => {
193183

194184
await expect(
195185
trackChatUpload(WORKSPACE_ID, USER_ID, CHAT_ID, S3_KEY, 'image.png', 'image/png', 1024)
196-
).rejects.toThrow('provenance write failed')
186+
).resolves.toEqual({ displayName: 'image.png' })
197187

198188
expect(dbChainMockFns.transaction).toHaveBeenCalledTimes(1)
189+
expect(mockReplaceWorkspaceFileSecretProvenanceInTx).not.toHaveBeenCalled()
199190
})
200191

201192
it('stamps message_id on the UPDATE arm when the birth message is known', async () => {

apps/sim/lib/uploads/contexts/workspace/workspace-file-manager.ts

Lines changed: 5 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -727,6 +727,9 @@ async function resolveClaimableChatUploadRow(
727727
* Allocates a collision-free `displayName` (the partial unique index on
728728
* (chat_id, display_name) WHERE context='mothership' enforces this) and returns it
729729
* so callers can surface the same name to the model in the VFS read hint.
730+
* This is a metadata-only operation: it preserves any content provenance already
731+
* attached to the uploaded bytes. Direct user uploads use the established
732+
* exact-empty/legacy classification and do not need a chat-time reclassification.
730733
*/
731734
export async function trackChatUpload(
732735
workspaceId: string,
@@ -793,10 +796,7 @@ export async function trackChatUpload(
793796
or(isNull(workspaceFiles.chatId), eq(workspaceFiles.chatId, chatId))
794797
)
795798
)
796-
.returning({
797-
id: workspaceFiles.id,
798-
contentUpdatedAt: workspaceFiles.contentUpdatedAt,
799-
})
799+
.returning({ id: workspaceFiles.id })
800800

801801
if (updated.length === 0) {
802802
// The ownership lookup is a separate statement, so re-assert every
@@ -807,13 +807,6 @@ export async function trackChatUpload(
807807
// to the chat-delete cascade.
808808
throw new WorkspaceFileKeyOwnershipError(s3Key)
809809
}
810-
811-
await replaceWorkspaceFileSecretProvenanceInTx(
812-
tx,
813-
updated[0].id,
814-
updated[0].contentUpdatedAt,
815-
EXACT_EMPTY_WORKSPACE_FILE_SECRET_PROVENANCE
816-
)
817810
})
818811

819812
logger.info(
@@ -840,20 +833,11 @@ export async function trackChatUpload(
840833
contentType,
841834
size,
842835
})
843-
.returning({
844-
id: workspaceFiles.id,
845-
contentUpdatedAt: workspaceFiles.contentUpdatedAt,
846-
})
836+
.returning({ id: workspaceFiles.id })
847837

848838
if (!inserted) {
849839
throw new Error(`Failed to track chat upload for key: ${s3Key}`)
850840
}
851-
await replaceWorkspaceFileSecretProvenanceInTx(
852-
tx,
853-
inserted.id,
854-
inserted.contentUpdatedAt,
855-
EXACT_EMPTY_WORKSPACE_FILE_SECRET_PROVENANCE
856-
)
857841
})
858842

859843
logger.info(`Tracked chat upload: ${fileName} (display: ${candidate}) for chat ${chatId}`)

apps/sim/lib/uploads/contexts/workspace/workspace-file-secret-provenance.test.ts

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -93,6 +93,36 @@ describe('workspace file secret provenance', () => {
9393
).rejects.toThrow('could not bind the tracked content version')
9494
})
9595

96+
it('binds the database content version within its JavaScript-visible millisecond', async () => {
97+
await replaceWorkspaceFileSecretProvenanceInTx(
98+
dbChainMock.db as unknown as DbTransaction,
99+
'file-1',
100+
CONTENT_UPDATED_AT,
101+
{ status: 'exact', entries: [] }
102+
)
103+
104+
expect(dbChainMockFns.where.mock.calls.at(-1)?.[0]).toEqual({
105+
type: 'and',
106+
conditions: [
107+
{ type: 'eq', left: 'id', right: 'file-1' },
108+
{ type: 'gte', left: 'contentUpdatedAt', right: CONTENT_UPDATED_AT },
109+
{
110+
type: 'lt',
111+
left: 'contentUpdatedAt',
112+
right: new Date(CONTENT_UPDATED_AT.getTime() + 1),
113+
},
114+
{ type: 'inArray', column: 'context', values: ['workspace', 'mothership'] },
115+
{
116+
type: 'or',
117+
conditions: [
118+
{ type: 'isNull', column: 'secretProvenanceVersion' },
119+
{ type: 'eq', left: 'secretProvenanceVersion', right: 1 },
120+
],
121+
},
122+
],
123+
})
124+
})
125+
96126
it('preserves provenance only from the exact preceding content version', async () => {
97127
const nextContentUpdatedAt = new Date('2026-08-04T00:00:01.000Z')
98128
queueTableRows(workspaceFileSecretProvenance, [{ contentUpdatedAt: CONTENT_UPDATED_AT }])

apps/sim/lib/uploads/contexts/workspace/workspace-file-secret-provenance.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import {
44
workspaceFileSecretProvenance,
55
workspaceFiles,
66
} from '@sim/db/schema'
7-
import { and, eq, inArray, isNull, or } from 'drizzle-orm'
7+
import { and, eq, gte, inArray, isNull, lt, or } from 'drizzle-orm'
88
import type { DbTransaction } from '@/lib/db/types'
99
import { importDurableSecretProvenance } from '@/lib/execution/durable-secret-provenance'
1010
import type { ResolvedSecretTraceRegistry } from '@/executor/utils/resolved-secret-trace-registry'
@@ -167,13 +167,15 @@ async function markWorkspaceFileSecretProvenanceTrackedInTx(
167167
fileId: string,
168168
contentUpdatedAt: Date
169169
): Promise<void> {
170+
const nextContentMillisecond = new Date(contentUpdatedAt.getTime() + 1)
170171
const [tracked] = await tx
171172
.update(workspaceFiles)
172173
.set({ secretProvenanceVersion: 1 })
173174
.where(
174175
and(
175176
eq(workspaceFiles.id, fileId),
176-
eq(workspaceFiles.contentUpdatedAt, contentUpdatedAt),
177+
gte(workspaceFiles.contentUpdatedAt, contentUpdatedAt),
178+
lt(workspaceFiles.contentUpdatedAt, nextContentMillisecond),
177179
inArray(workspaceFiles.context, ['workspace', 'mothership']),
178180
or(
179181
isNull(workspaceFiles.secretProvenanceVersion),

apps/sim/providers/index.test.ts

Lines changed: 24 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -832,37 +832,35 @@ describe('executeProviderRequest — model secret projection', () => {
832832
expect(mockExecuteRequest.mock.calls[0][0].messages[0].files).toEqual([safe])
833833
})
834834

835-
it('continues text-only when file provenance lookup is unavailable', async () => {
835+
it('fails explicitly when file provenance lookup is unavailable', async () => {
836836
mockFilterModelSafeWorkspaceFileAttachments.mockRejectedValueOnce(new Error('db unavailable'))
837837

838-
await executeProviderRequest('openai', {
839-
model: 'test-model',
840-
workspaceId: 'ws-1',
841-
messages: [
842-
{
843-
role: 'user',
844-
content: 'Continue without the file',
845-
files: [
846-
{
847-
id: 'wf-file',
848-
name: 'file.txt',
849-
url: '/file',
850-
size: 10,
851-
type: 'text/plain',
852-
key: 'workspace/ws-1/file.txt',
853-
},
854-
],
855-
},
856-
],
857-
})
858-
859-
expect(mockExecuteRequest).toHaveBeenCalledWith(
860-
expect.objectContaining({
838+
await expect(
839+
executeProviderRequest('openai', {
840+
model: 'test-model',
841+
workspaceId: 'ws-1',
861842
messages: [
862-
expect.objectContaining({ content: 'Continue without the file', files: undefined }),
843+
{
844+
role: 'user',
845+
content: 'Review the file',
846+
files: [
847+
{
848+
id: 'wf-file',
849+
name: 'file.txt',
850+
url: '/file',
851+
size: 10,
852+
type: 'text/plain',
853+
key: 'workspace/ws-1/file.txt',
854+
},
855+
],
856+
},
863857
],
864858
})
865-
)
859+
).rejects.toThrow('File attachments could not be verified for model use')
860+
861+
expect(mockAttachLargeFileRemoteUrls).not.toHaveBeenCalled()
862+
expect(mockUploadLargeFilesToProvider).not.toHaveBeenCalled()
863+
expect(mockExecuteRequest).not.toHaveBeenCalled()
866864
})
867865

868866
it('projects JSON arguments without mutating attachment metadata before serialization', async () => {

apps/sim/providers/index.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -298,11 +298,11 @@ async function omitUnsafeProviderFileAttachments(
298298
workspaceId: request.workspaceId,
299299
})
300300
} catch (error) {
301-
logger.warn('Workspace file secret provenance could not be verified; omitting attachments', {
301+
logger.error('Workspace file secret provenance could not be verified', {
302302
attachmentCount: attachments.length,
303303
error: toError(error).message,
304304
})
305-
safeAttachments = []
305+
throw new Error('File attachments could not be verified for model use')
306306
}
307307

308308
if (safeAttachments.length === attachments.length) return request

0 commit comments

Comments
 (0)