Skip to content

Commit 0d43f1c

Browse files
committed
fix(workflows): align advertised file validation and reserve unsafe names
1 parent c68d930 commit 0d43f1c

5 files changed

Lines changed: 61 additions & 15 deletions

File tree

‎apps/sim/lib/workflows/input-format.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -84,7 +84,7 @@ export type InputFormatFile = Pick<UserFile, 'id' | 'name' | 'url' | 'size' | 't
8484
* mirrors `normalizeStartFile` exactly (including the parse, so a malformed
8585
* internal URL is rejected rather than accepted on the prefix alone).
8686
*/
87-
export function hasRecoverableFileKey(file: InputFormatFile): boolean {
87+
function hasRecoverableFileKey(file: InputFormatFile): boolean {
8888
if (typeof file.key === 'string' && file.key.length > 0) return true
8989
if (typeof file.url !== 'string' || !isInternalFileUrl(file.url)) return false
9090
try {

‎apps/sim/lib/workflows/input-schema.test.ts‎

Lines changed: 28 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { describe, expect, it } from 'vitest'
22
import { z } from 'zod'
3+
import { compileMcpToolSchema } from '@/lib/mcp/tool-schema'
34
import { generateToolInputSchema } from '@/lib/mcp/workflow-tool-schema'
45
import { generateWorkflowInputShape } from '@/lib/workflows/input-schema'
56
import type { InputFormatField } from '@/lib/workflows/types'
@@ -29,7 +30,7 @@ describe('workflow input schemas', () => {
2930
type: 'array',
3031
items: {
3132
type: 'object',
32-
required: ['id', 'name', 'url', 'size', 'type'],
33+
required: ['id', 'name', 'url', 'size', 'type', 'key'],
3334
properties: {
3435
key: { type: 'string' },
3536
type: { type: 'string' },
@@ -62,12 +63,34 @@ describe('workflow input schemas', () => {
6263
expect(schema.safeParse({ files: [invalidFile] }).success).toBe(false)
6364
})
6465

65-
it('accepts an internal file URL whose storage key can be recovered', () => {
66-
const { key, ...internalFile } = file
67-
internalFile.url = `/api/files/serve/s3/${encodeURIComponent(key)}?context=workspace`
68-
expect(schema.parse({ files: [internalFile] })).toEqual({ files: [internalFile] })
66+
it.each([
67+
{ value: file, valid: true },
68+
{ value: { ...file, key: '' }, valid: false },
69+
{ value: { ...file, key: undefined }, valid: false },
70+
{
71+
value: {
72+
...file,
73+
key: undefined,
74+
url: `/api/files/serve/s3/${encodeURIComponent(file.key)}?context=workspace`,
75+
},
76+
valid: false,
77+
},
78+
])('advertises the same storage-key requirement it validates: $valid', ({ value, valid }) => {
79+
const validate = compileMcpToolSchema(generateToolInputSchema(inputFormat))
80+
const input = { files: [value] }
81+
expect(validate(input)).toBe(valid)
82+
expect(schema.safeParse(input).success).toBe(valid)
6983
})
7084

85+
it.each(['__proto__', 'constructor', 'prototype'])(
86+
'rejects reserved input name %s before schema construction',
87+
(name) => {
88+
const fields: InputFormatField[] = [{ name, type: 'string' }]
89+
expect(() => generateWorkflowInputShape(fields)).toThrow('is reserved')
90+
expect(() => generateToolInputSchema(fields)).toThrow('is reserved')
91+
}
92+
)
93+
7194
it('shares required fields, descriptions, and arbitrary array items across schemas', () => {
7295
const fields: InputFormatField[] = [
7396
{ name: ' message ', type: 'string', description: ' User message ' },

‎apps/sim/lib/workflows/input-schema.ts‎

Lines changed: 10 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,10 @@
11
import { z } from 'zod'
2-
import { hasRecoverableFileKey, type InputFormatFile } from '@/lib/workflows/input-format'
32
import type { InputFormatField } from '@/lib/workflows/types'
3+
import type { UserFile } from '@/executor/types'
4+
import { isSafeKey } from '@/tools/safe-assign'
45

56
/**
6-
* Uploaded file references accepted by Start inputs. File ownership is checked
7+
* Canonical uploaded file references for workflow inputs. File ownership is checked
78
* by the executor against the run's workspace, after input-shape validation.
89
*/
910
const workflowInputFileSchema = z
@@ -13,13 +14,11 @@ const workflowInputFileSchema = z
1314
url: z.string().min(1, 'File url cannot be empty'),
1415
size: z.number().nonnegative(),
1516
type: z.string().min(1, 'File MIME type cannot be empty'),
16-
key: z.string().optional(),
17+
key: z.string().min(1, 'File storage key cannot be empty'),
1718
})
18-
.passthrough()
19-
.refine(hasRecoverableFileKey, {
20-
message: 'File must include a storage key or an internal file URL with a recoverable key',
21-
path: ['key'],
22-
}) satisfies z.ZodType<InputFormatFile>
19+
.passthrough() satisfies z.ZodType<
20+
Pick<UserFile, 'id' | 'name' | 'url' | 'size' | 'type' | 'key'>
21+
>
2322

2423
function fieldTypeToSchema(type: string | undefined): z.ZodType {
2524
switch (type) {
@@ -51,6 +50,9 @@ export function generateWorkflowInputShape(inputFormat: InputFormatField[]): z.Z
5150
for (const field of inputFormat) {
5251
const name = field.name?.trim()
5352
if (!name) continue
53+
if (!isSafeKey(name)) {
54+
throw new Error(`Workflow input name "${name}" is reserved. Rename this input field.`)
55+
}
5456

5557
const schema = fieldTypeToSchema(field.type).describe(
5658
field.description?.trim() ||

‎apps/sim/lib/workflows/triggers/run-options.test.ts‎

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -102,6 +102,23 @@ describe('file inputs through workflow run options', () => {
102102
expect(option.mockPayload).toEqual({ files: [file] })
103103
expect(validateTriggerInput(option, option.mockPayload)).toEqual({ ok: true })
104104
})
105+
106+
it('normalizes an editor file URL to canonical metadata in the sample payload', () => {
107+
const { key, ...editorFile } = file
108+
editorFile.url = `/api/files/serve/s3/${encodeURIComponent(key)}?context=workspace`
109+
const [option] = resolveTriggerRunOptions({
110+
start: {
111+
...block,
112+
subBlocks: {
113+
inputFormat: {
114+
value: [{ name: 'files', type: 'file[]', value: JSON.stringify([editorFile]) }],
115+
},
116+
},
117+
},
118+
})
119+
expect(option.mockPayload).toEqual({ files: [{ ...editorFile, key }] })
120+
expect(validateTriggerInput(option, option.mockPayload)).toEqual({ ok: true })
121+
})
105122
})
106123

107124
describe('validateTriggerInput', () => {

‎apps/sim/lib/workflows/triggers/run-options.ts‎

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { isRecordLike } from '@sim/utils/object'
22
import { z } from 'zod'
33
import { generateToolInputSchema } from '@/lib/mcp/workflow-tool-schema'
4+
import { parseInternalFileUrl } from '@/lib/uploads/utils/file-utils'
45
import { normalizeInputFormatValue, parseInputFormatFiles } from '@/lib/workflows/input-format'
56
import { generateWorkflowInputShape } from '@/lib/workflows/input-schema'
67
import {
@@ -146,7 +147,10 @@ function buildFieldsSample(inputFormat: InputFormatField[]): Record<string, unkn
146147
const name = field.name?.trim()
147148
if (!name) continue
148149
if (field.type === 'file[]') {
149-
sample[name] = parseInputFormatFiles(field.value)
150+
sample[name] = parseInputFormatFiles(field.value).map((file) => ({
151+
...file,
152+
key: file.key || parseInternalFileUrl(file.url).key,
153+
}))
150154
continue
151155
}
152156
sample[name] =

0 commit comments

Comments
 (0)