Skip to content

Commit e698b10

Browse files
committed
fix(attachments): model egress attachments
1 parent 3510b0c commit e698b10

4 files changed

Lines changed: 82 additions & 45 deletions

File tree

apps/sim/lib/copilot/chat/payload.test.ts

Lines changed: 39 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -316,7 +316,12 @@ describe('buildCopilotRequestPayload', () => {
316316
workspaceId: 'ws-1',
317317
chatId: 'chat-1',
318318
fileAttachments: [
319-
{ id: 'a1', key: 'workspace/ws-1/1731000000000-ab12cd34-payroll.xlsx', size: 1 },
319+
{
320+
id: 'a1',
321+
key: 'workspace/ws-1/1731000000000-ab12cd34-payroll.xlsx',
322+
filename: 'payroll.xlsx',
323+
size: 1,
324+
},
320325
],
321326
}
322327

@@ -350,6 +355,39 @@ describe('buildCopilotRequestPayload', () => {
350355
'msg-1'
351356
)
352357
})
358+
359+
it('includes successfully prepared attachments in the model context', async () => {
360+
const payload = await buildCopilotRequestPayload(
361+
{ ...attachmentParams, userPermission: 'write' },
362+
{ selectedModel: 'claude-opus-4-8' }
363+
)
364+
365+
expect(payload.context).toEqual([
366+
{
367+
type: 'uploaded_file',
368+
content: [
369+
'File "payroll.xlsx" (application/octet-stream, 1 bytes) uploaded.',
370+
'Read with: read("uploads/payroll.xlsx")',
371+
'To save permanently: materialize_file(fileName: "payroll.xlsx")',
372+
].join('\n'),
373+
},
374+
])
375+
})
376+
377+
it('fails the request when an authorized attachment cannot be prepared', async () => {
378+
const cause = new Error('provenance sidecar unavailable')
379+
mockTrackChatUpload.mockRejectedValueOnce(cause)
380+
381+
await expect(
382+
buildCopilotRequestPayload(
383+
{ ...attachmentParams, userPermission: 'write' },
384+
{ selectedModel: 'claude-opus-4-8' }
385+
)
386+
).rejects.toMatchObject({
387+
message: 'Failed to prepare attached file "payroll.xlsx" for Copilot. Please try again.',
388+
cause,
389+
})
390+
})
353391
})
354392

355393
it('passes workspaceContext through to the Go request payload', async () => {

apps/sim/lib/copilot/chat/payload.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -395,11 +395,16 @@ export async function buildCopilotRequestPayload(
395395
content: lines.join('\n'),
396396
})
397397
} catch (err) {
398+
const cause = toError(err)
398399
logger.warn('Failed to track chat upload', {
399400
filename,
400401
chatId,
401-
error: toError(err).message,
402+
error: cause.message,
402403
})
404+
throw new Error(
405+
`Failed to prepare attached file "${filename}" for Copilot. Please try again.`,
406+
{ cause }
407+
)
403408
}
404409
}
405410
}

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

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -136,7 +136,7 @@ describe('workspace file secret provenance', () => {
136136
)
137137
})
138138

139-
it('treats only canonically bound untouched legacy files as model-safe without a sidecar', async () => {
139+
it('classifies attachments by their canonical storage key without requiring a database file id', async () => {
140140
queueTableRows(workspaceFiles, [
141141
{
142142
id: 'safe-id',
@@ -219,9 +219,11 @@ describe('workspace file secret provenance', () => {
219219

220220
const attachments = [
221221
{ id: 'safe-id', key: 'safe-key' },
222+
{ key: 'safe-key' },
223+
{ id: 'file-1700000000000', key: 'safe-key' },
222224
{ id: 'tracked-no-sidecar-id', key: 'tracked-no-sidecar-key' },
223225
{ id: 'wrong-id', key: 'safe-key' },
224-
{ id: 'tainted-id', key: 'tainted-key' },
226+
{ id: 'safe-id', key: 'tainted-key' },
225227
{ id: 'unknown-id', key: 'unknown-key' },
226228
{ id: 'other-workspace-id', key: 'other-workspace-key' },
227229
{ id: 'pre-marker-sidecar-id', key: 'pre-marker-sidecar-key' },
@@ -234,6 +236,9 @@ describe('workspace file secret provenance', () => {
234236
filterModelSafeWorkspaceFileAttachments(attachments, { workspaceId: 'workspace-1' })
235237
).resolves.toEqual([
236238
{ id: 'safe-id', key: 'safe-key' },
239+
{ key: 'safe-key' },
240+
{ id: 'file-1700000000000', key: 'safe-key' },
241+
{ id: 'wrong-id', key: 'safe-key' },
237242
{ id: 'pre-marker-sidecar-id', key: 'pre-marker-sidecar-key' },
238243
{ id: 'synthetic-execution-id', key: 'untracked-context-key' },
239244
{ id: 'legacy-id', key: 'legacy-key' },

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

Lines changed: 30 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -60,7 +60,6 @@ export interface WorkspaceFileSecretProvenanceEnvelope<T> {
6060
}
6161

6262
interface ModelSafeWorkspaceFileRow {
63-
id: string
6463
key: string
6564
workspaceId: string | null
6665
context: string
@@ -543,9 +542,12 @@ export async function importWorkspaceFileSecretProvenanceForRuntime(args: {
543542
}
544543

545544
/**
546-
* Removes model attachments whose canonical workspace-file record is tainted, unknown, or does
547-
* not match the supplied file id. Missing legacy records remain compatible; any persisted record
548-
* is classified exclusively by its trusted key/id binding and private provenance row.
545+
* Removes model attachments whose canonical workspace-file record is tainted or unknown.
546+
* Missing legacy records remain compatible; persisted records are classified by their unique
547+
* active storage-key binding and private provenance row. Attachment ids are deliberately ignored:
548+
* older persisted workflows omit them and file normalization may synthesize a runtime-only id.
549+
* This classification is not file authorization; callers still enforce storage access before
550+
* reading bytes or issuing a provider URL.
549551
*/
550552
export async function filterModelSafeWorkspaceFileAttachments<
551553
TAttachment extends WorkspaceFileAttachmentIdentity,
@@ -567,32 +569,14 @@ export async function filterModelSafeWorkspaceFileAttachments<
567569
]
568570
if (keys.length === 0) return [...attachments]
569571

570-
const rows = await db
571-
.select({
572-
id: workspaceFiles.id,
573-
key: workspaceFiles.key,
574-
workspaceId: workspaceFiles.workspaceId,
575-
context: workspaceFiles.context,
576-
fileContentUpdatedAt: workspaceFiles.contentUpdatedAt,
577-
secretProvenanceVersion: workspaceFiles.secretProvenanceVersion,
578-
provenanceContentUpdatedAt: workspaceFileSecretProvenance.contentUpdatedAt,
579-
status: workspaceFileSecretProvenance.status,
580-
entries: workspaceFileSecretProvenance.entries,
581-
})
582-
.from(workspaceFiles)
583-
.leftJoin(
584-
workspaceFileSecretProvenance,
585-
eq(workspaceFileSecretProvenance.fileId, workspaceFiles.id)
586-
)
587-
.where(and(inArray(workspaceFiles.key, keys), isNull(workspaceFiles.deletedAt)))
572+
const rows = await loadModelSafeWorkspaceFileRows(keys)
588573

589574
const rowByKey = new Map(rows.map((row) => [row.key, row]))
590575
return attachments.filter((attachment) => {
591576
if (typeof attachment.key !== 'string' || attachment.key.length === 0) return true
592577
const row = rowByKey.get(attachment.key)
593578
if (!row) return true
594579
if (row.context !== 'workspace' && row.context !== 'mothership') return true
595-
if (typeof attachment.id !== 'string' || attachment.id !== row.id) return false
596580
return isModelSafeWorkspaceFileRow(row, options.workspaceId)
597581
})
598582
}
@@ -614,6 +598,28 @@ function isModelSafeWorkspaceFileRow(
614598
return row.entries.length === 0
615599
}
616600

601+
async function loadModelSafeWorkspaceFileRows(
602+
keys: readonly string[]
603+
): Promise<ModelSafeWorkspaceFileRow[]> {
604+
return db
605+
.select({
606+
key: workspaceFiles.key,
607+
workspaceId: workspaceFiles.workspaceId,
608+
context: workspaceFiles.context,
609+
fileContentUpdatedAt: workspaceFiles.contentUpdatedAt,
610+
secretProvenanceVersion: workspaceFiles.secretProvenanceVersion,
611+
provenanceContentUpdatedAt: workspaceFileSecretProvenance.contentUpdatedAt,
612+
status: workspaceFileSecretProvenance.status,
613+
entries: workspaceFileSecretProvenance.entries,
614+
})
615+
.from(workspaceFiles)
616+
.leftJoin(
617+
workspaceFileSecretProvenance,
618+
eq(workspaceFileSecretProvenance.fileId, workspaceFiles.id)
619+
)
620+
.where(and(inArray(workspaceFiles.key, [...keys]), isNull(workspaceFiles.deletedAt)))
621+
}
622+
617623
/**
618624
* Verifies a server-authorized storage key before its bytes or signed URL cross a model boundary.
619625
* Unlike attachment filtering, the key has already passed access control, so no caller-provided
@@ -642,24 +648,7 @@ export async function areModelSafeWorkspaceFileKeys(
642648
throw new Error('Too many file keys to verify secret provenance')
643649
}
644650

645-
const rows = await db
646-
.select({
647-
id: workspaceFiles.id,
648-
key: workspaceFiles.key,
649-
workspaceId: workspaceFiles.workspaceId,
650-
context: workspaceFiles.context,
651-
fileContentUpdatedAt: workspaceFiles.contentUpdatedAt,
652-
secretProvenanceVersion: workspaceFiles.secretProvenanceVersion,
653-
provenanceContentUpdatedAt: workspaceFileSecretProvenance.contentUpdatedAt,
654-
status: workspaceFileSecretProvenance.status,
655-
entries: workspaceFileSecretProvenance.entries,
656-
})
657-
.from(workspaceFiles)
658-
.leftJoin(
659-
workspaceFileSecretProvenance,
660-
eq(workspaceFileSecretProvenance.fileId, workspaceFiles.id)
661-
)
662-
.where(and(inArray(workspaceFiles.key, uniqueKeys), isNull(workspaceFiles.deletedAt)))
651+
const rows = await loadModelSafeWorkspaceFileRows(uniqueKeys)
663652

664653
return rows.every(
665654
(row) =>

0 commit comments

Comments
 (0)