Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
122 changes: 77 additions & 45 deletions apps/sim/executor/handlers/agent/agent-handler.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1040,55 +1040,87 @@ describe('AgentBlockHandler', () => {
}
})

it('omits only a generated document whose embedded contributor is not model-safe', async () => {
const key = 'workspace/ws-1/report.pdf'
mockContext.workspaceId = 'ws-1'
const hydrationSpy = vi
.spyOn(userFileBase64, 'hydrateUserFilesWithBase64')
.mockImplementationOnce(async (files, options) => {
await options.onServableFileContributors?.(files[0], [
{
fileId: 'image-1',
key: 'workspace/ws-1/image-1.png',
context: 'workspace',
contentUpdatedAt: new Date('2026-08-06T00:00:00.000Z'),
},
])
return files.map((file) => ({ ...file, base64: 'JVBERi0=' }))
})
mockImportWorkspaceFileSecretProvenanceForModelView.mockResolvedValueOnce(false)
it.each([
{ safe: false, includeSafeFile: false },
{ safe: false, includeSafeFile: true },
{ safe: true, includeSafeFile: true },
])(
'continues after document contributor admission (safe=$safe, mixed=$includeSafeFile)',
async ({ safe, includeSafeFile }) => {
const key = 'workspace/ws-1/report.pdf'
mockContext.workspaceId = 'ws-1'
const hydrationSpy = vi
.spyOn(userFileBase64, 'hydrateUserFilesWithBase64')
.mockImplementationOnce(async (files, options) => {
await options.onServableFileContributors?.(files[0], [
{
fileId: 'image-1',
key: 'workspace/ws-1/image-1.png',
context: 'workspace',
contentUpdatedAt: new Date('2026-08-06T00:00:00.000Z'),
},
])
return files.map((file) => ({ ...file, base64: 'JVBERi0=' }))
})
mockImportWorkspaceFileSecretProvenanceForModelView.mockResolvedValueOnce(safe)

try {
mockGetProviderFromModel.mockReturnValue('openai')
try {
mockGetProviderFromModel.mockReturnValue('openai')

await handler.execute(mockContext, mockBlock, {
model: 'gpt-4o',
userPrompt: 'Analyze this document',
files: [
{
id: 'file-1',
name: 'report.pdf',
path: `/api/files/serve/${encodeURIComponent(key)}?context=workspace`,
key,
size: 128,
type: 'text/x-python-pdf',
},
],
apiKey: 'test-api-key',
})

expect(mockExecuteProviderRequest.mock.calls[0][1].messages.at(-1)?.files).toEqual([])
expect(mockImportWorkspaceFileSecretProvenanceForModelView).toHaveBeenCalledWith(
expect.objectContaining({
workspaceId: mockContext.workspaceId,
view: 'opaque',
identity: expect.objectContaining({ fileId: 'image-1' }),
await handler.execute(mockContext, mockBlock, {
model: 'gpt-4o',
userPrompt: 'Analyze this document',
files: [
{
id: 'file-1',
name: 'report.pdf',
path: `/api/files/serve/${encodeURIComponent(key)}?context=workspace`,
key,
size: 128,
type: 'text/x-python-pdf',
},
...(includeSafeFile
? [
{
id: 'file-2',
name: 'safe.pdf',
path: '/safe.pdf',
key: 'workspace/ws-1/safe.pdf',
size: 128,
type: 'application/pdf',
},
]
: []),
],
apiKey: 'test-api-key',
})
)
} finally {
hydrationSpy.mockRestore()

expect(mockExecuteProviderRequest).toHaveBeenCalledOnce()
const sent = mockExecuteProviderRequest.mock.calls[0][1].messages.at(-1)
expect(sent.files.map((file: { id: string }) => file.id)).toEqual([
...(safe ? ['file-1'] : []),
...(includeSafeFile ? ['file-2'] : []),
])
if (safe) {
expect(sent.content).toBe('Analyze this document')
} else {
expect(sent.content).toMatch(
/^Analyze this document\n\nAttachment error: 1 requested file attachment was not provided/
)
expect(JSON.stringify(sent)).not.toContain(key)
}
expect(mockImportWorkspaceFileSecretProvenanceForModelView).toHaveBeenCalledWith(
expect.objectContaining({
workspaceId: mockContext.workspaceId,
view: 'opaque',
identity: expect.objectContaining({ fileId: 'image-1' }),
})
)
} finally {
hydrationSpy.mockRestore()
}
}
})
)

it('should reject files for providers without attachment support', async () => {
const inputs = {
Expand Down
10 changes: 9 additions & 1 deletion apps/sim/executor/handlers/agent/agent-handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,10 @@ import {
type RawFileInput,
tryInferContextFromKey,
} from '@/lib/uploads/utils/file-utils'
import { selectModelBoundFileInputPaths } from '@/lib/uploads/utils/model-input'
import {
appendUnavailableAttachmentNotice,
selectModelBoundFileInputPaths,
} from '@/lib/uploads/utils/model-input'
import { hydrateUserFilesWithBase64 } from '@/lib/uploads/utils/user-file-base64.server'
import { resolveCustomBlockToolBinding } from '@/lib/workflows/custom-blocks/operations'
import {
Expand Down Expand Up @@ -1602,8 +1605,13 @@ export class AgentBlockHandler implements BlockHandler {
)
}

const omittedCount = hydratedFiles.length - modelSafeHydratedFiles.length
nextMessages[messageIndex] = {
...message,
content:
omittedCount > 0
? appendUnavailableAttachmentNotice(message.content, omittedCount)
: message.content,
files: modelSafeHydratedFiles,
}
}
Expand Down
113 changes: 96 additions & 17 deletions apps/sim/lib/copilot/request/lifecycle/run.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -665,31 +665,110 @@ describe('runCopilotLifecycle', () => {
expect(sent.fileAttachments).toEqual([{ name: 'TOKEN.txt', key: 'safe-key' }])
})

it('omits only unsafe durable attachments before the initial Go request', async () => {
const unsafe = { id: 'wf-unsafe', name: 'unsafe.txt', key: 'workspace/ws-1/unsafe.txt' }
const safe = { id: 'wf-safe', name: 'safe.txt', key: 'workspace/ws-1/safe.txt' }
mockFilterModelSafeWorkspaceFileAttachments.mockResolvedValueOnce([safe])
let capturedRequestBody = ''
mockRunStreamLoop.mockImplementationOnce(async (_url: string, request: RequestInit) => {
capturedRequestBody = String(request.body)
})

await runCopilotLifecycle(
{
it.each([
{ key: 'attachments', includeSafeFile: false },
{ key: 'attachments', includeSafeFile: true },
{ key: 'fileAttachments', includeSafeFile: false },
{ key: 'fileAttachments', includeSafeFile: true },
])(
'continues with an error notice for refused $key (mixed=$includeSafeFile)',
async ({ key, includeSafeFile }) => {
const unsafe = {
id: 'wf-private',
name: 'private-filename.txt',
key: 'private-storage-key',
base64: 'private-bytes',
}
const safe = { id: 'wf-safe', name: 'safe.txt', key: 'workspace/ws-1/safe.txt' }
const safeFiles = includeSafeFile ? [safe] : []
mockFilterModelSafeWorkspaceFileAttachments.mockResolvedValueOnce(safeFiles)
const onError = vi.fn()
mockRunStreamLoop.mockImplementationOnce(async (_url, _request, context) => {
context.accumulatedContent = 'I can continue with the available inputs.'
context.completionStatus = MothershipStreamV1CompletionStatus.complete
})
const payload = {
message: 'Review files',
fileAttachments: [unsafe, safe],
[key]: [...safeFiles, unsafe],
workspaceId: 'ws-1',
messageId: 'stream-file-provenance',
},
{
}
const originalPayload = structuredClone(payload)

const result = await runCopilotLifecycle(payload, {
userId: 'user-1',
workspaceId: 'ws-1',
executionContext: { userId: 'user-1', workflowId: '', workspaceId: 'ws-1' },
onError,
})

expect(result).toMatchObject({
success: true,
content: 'I can continue with the available inputs.',
})
expect(onError).not.toHaveBeenCalled()
expect(mockRunStreamLoop).toHaveBeenCalledOnce()
const sent = JSON.parse(String(mockRunStreamLoop.mock.calls[0][1].body))
expect(sent.message).toMatch(
/^Review files\n\nAttachment error: 1 requested file attachment was not provided/
)
expect(sent[key] ?? []).toEqual(safeFiles)
expect(JSON.stringify(sent)).not.toContain('private-')
expect(mockFilterModelSafeWorkspaceFileAttachments).toHaveBeenCalledWith(
[...safeFiles, unsafe],
{ workspaceId: 'ws-1' }
)
expect(payload).toEqual(originalPayload)
}
)

it.each(['messages', 'both', 'attachment-only', 'system-only'])(
'reports combined attachment refusals in %s payloads without changing history',
async (shape) => {
const history = {
role: 'assistant',
content: 'Previous response',
tool_calls: [{ id: 'existing-call' }],
}
const messages =
shape === 'system-only'
? [{ role: 'system', content: 'System context' }]
: [history, { role: 'user', content: 'Review files' }]
const payload = {
...(shape === 'both' ? { message: 'Review files' } : {}),
...(shape === 'attachment-only' ? {} : { messages }),
attachments: [{ key: 'private-first-file' }],
fileAttachments: [{ key: 'private-second-file' }],
}
)
const original = structuredClone(payload)
mockFilterModelSafeWorkspaceFileAttachments
.mockResolvedValueOnce([])
.mockResolvedValueOnce([])
mockRunStreamLoop.mockResolvedValueOnce(undefined)

expect(JSON.parse(capturedRequestBody).fileAttachments).toEqual([safe])
})
const result = await runCopilotLifecycle(payload, {
userId: 'user-1',
workspaceId: 'ws-1',
executionContext: { userId: 'user-1', workflowId: '', workspaceId: 'ws-1' },
})

expect(result.success).toBe(true)
const sent = JSON.parse(String(mockRunStreamLoop.mock.calls[0][1].body))
const notice = 'Attachment error: 2 requested file attachments were not provided'
if (shape === 'both' || shape === 'attachment-only') expect(sent.message).toContain(notice)
if (shape !== 'attachment-only') {
expect(sent.messages[0]).toEqual(messages[0])
expect(sent.messages.at(-1)).toMatchObject({
role: 'user',
content: expect.stringContaining(notice),
})
}
expect(sent).not.toHaveProperty('attachments')
expect(sent).not.toHaveProperty('fileAttachments')
expect(JSON.stringify(sent)).not.toContain('private-')
expect(payload).toEqual(original)
}
)

it('rejects when durable attachment provenance cannot be verified', async () => {
mockFilterModelSafeWorkspaceFileAttachments.mockRejectedValueOnce(new Error('db unavailable'))
Expand Down
45 changes: 35 additions & 10 deletions apps/sim/lib/copilot/request/lifecycle/run.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import type { PermissionType } from '@sim/platform-authz/workspace'
import { getErrorMessage, toError } from '@sim/utils/errors'
import { interruptibleSleep, sleep } from '@sim/utils/helpers'
import { generateId } from '@sim/utils/id'
import { omit } from '@sim/utils/object'
import { isPlainRecord, omit } from '@sim/utils/object'
import { workspaceSearchFiltersSchema } from '@/lib/api/contracts/knowledge/search'
import {
type AttributedBillingRequestEnvelope,
Expand Down Expand Up @@ -72,6 +72,7 @@ import { env } from '@/lib/core/config/env'
import { isCopilotToolPermissionsEnabled, isHosted } from '@/lib/core/config/env-flags'
import { isWorkspaceCapabilityWithheld } from '@/lib/permission-groups/capability-assertions'
import { filterModelSafeWorkspaceFileAttachments } from '@/lib/uploads/contexts/workspace/workspace-file-secret-provenance'
import { appendUnavailableAttachmentNotice } from '@/lib/uploads/utils/model-input'
import type { ExecutorDelegationOrigin } from '@/executor/types'
import { refuseResolvedSecretProjection } from '@/executor/utils/resolved-secret-projection-refusal'
import type { ResolvedSecretTraceRegistry } from '@/executor/utils/resolved-secret-trace-registry'
Expand All @@ -95,11 +96,12 @@ class CopilotModelContentProjectionError extends Error {
}
}

async function omitUnsafeInitialCopilotAttachments(
async function prepareInitialCopilotAttachmentsForModel(
payload: Record<string, unknown>,
workspaceId?: string
): Promise<Record<string, unknown>> {
let projected = payload
let omittedCount = 0
for (const key of ['attachments', 'fileAttachments'] as const) {
if (!Object.hasOwn(projected, key)) continue
const attachments = projected[key]
Expand Down Expand Up @@ -129,21 +131,44 @@ async function omitUnsafeInitialCopilotAttachments(
}

if (safeAttachments.length === attachments.length) continue
omittedCount += attachments.length - safeAttachments.length
logger.warn('Omitting Copilot attachments with unsafe secret provenance', {
attachmentCount: attachments.length,
omittedCount: attachments.length - safeAttachments.length,
})
projected =
safeAttachments.length > 0 ? { ...projected, [key]: safeAttachments } : omit(projected, [key])
}
return projected
}
if (omittedCount === 0) return projected

async function filterInitialCopilotAttachmentsForModel(
payload: Record<string, unknown>,
workspaceId?: string
): Promise<Record<string, unknown>> {
return omitUnsafeInitialCopilotAttachments(payload, workspaceId)
if (typeof projected.message === 'string') {
projected = {
...projected,
message: appendUnavailableAttachmentNotice(projected.message, omittedCount),
}
}
if (Array.isArray(projected.messages)) {
const messages: unknown[] = [...projected.messages]
let notified = false
for (let index = messages.length - 1; index >= 0; index--) {
const message = messages[index]
if (!isPlainRecord(message) || message.role !== 'user' || typeof message.content !== 'string')
continue
messages[index] = {
...message,
content: appendUnavailableAttachmentNotice(message.content, omittedCount),
}
notified = true
break
}
if (!notified) {
messages.push({ role: 'user', content: appendUnavailableAttachmentNotice('', omittedCount) })
}
projected = { ...projected, messages }
} else if (typeof projected.message !== 'string') {
projected = { ...projected, message: appendUnavailableAttachmentNotice('', omittedCount) }
}
return projected
}

async function ensureModelEgressRegistry(
Expand Down Expand Up @@ -412,7 +437,7 @@ export async function runCopilotLifecycle(
}),
}
}
const modelSafeRequestPayload = await filterInitialCopilotAttachmentsForModel(
const modelSafeRequestPayload = await prepareInitialCopilotAttachmentsForModel(
requestPayload,
lifecycleOptions.workspaceId
)
Expand Down
Loading
Loading