diff --git a/apps/sim/executor/variables/resolver.test.ts b/apps/sim/executor/variables/resolver.test.ts index 4274c37b581..dffd4a0e48f 100644 --- a/apps/sim/executor/variables/resolver.test.ts +++ b/apps/sim/executor/variables/resolver.test.ts @@ -10,10 +10,16 @@ import { LARGE_ARRAY_MANIFEST_VERSION, type LargeArrayManifest, } from '@/lib/execution/payloads/large-array-manifest-metadata' +import { + collectSandboxFileMountRefs, + replaceSandboxFileMountRefs, +} from '@/lib/execution/payloads/sandbox-file-mount-ref' +import { StartBlockPath } from '@/lib/workflows/triggers/triggers' import { BlockType } from '@/executor/constants' import { ExecutionState } from '@/executor/execution/state' import type { ExecutionContext } from '@/executor/types' import { ResolvedSecretTraceRegistry } from '@/executor/utils/resolved-secret-trace-registry' +import { buildStartBlockOutput } from '@/executor/utils/start-block' import { VariableResolver } from '@/executor/variables/resolver' import { navigatePathAsync } from '@/executor/variables/resolvers/reference-async.server' import type { SerializedBlock, SerializedWorkflow } from '@/serializer/types' @@ -127,6 +133,79 @@ function createResolver( } } +describe('Start file path references', () => { + const workspaceId = '11111111-1111-4111-8111-111111111111' + const key = `workspace/${workspaceId}/photo.png` + const uploadedFile = { + id: 'file-1', + name: 'photo.png', + size: 128, + type: 'image/png', + } + + it.each([ + { + language: 'shell', + file: { ...uploadedFile, key, url: 'https://storage.example.com/photo.png' }, + }, + { + language: 'shell', + file: { + ...uploadedFile, + url: `/api/files/serve/s3/${encodeURIComponent(key)}?context=workspace`, + }, + }, + { + language: 'python', + file: { ...uploadedFile, key, url: 'https://storage.example.com/photo.png' }, + }, + { + language: 'javascript', + file: { ...uploadedFile, key, url: 'https://storage.example.com/photo.png' }, + }, + ])('mounts a Start upload referenced from $language code', async ({ language, file }) => { + const start = createBlock('start', 'Start', 'start_trigger', { + inputFormat: [{ name: 'files', type: 'file[]', value: '' }], + }) + const functionBlock = createBlock('function', 'Function', BlockType.FUNCTION, { language }) + const output = buildStartBlockOutput({ + resolution: { blockId: start.id, block: start, path: StartBlockPath.UNIFIED }, + workspaceId, + workflowInput: { input: 'Edit the image', files: [file] }, + }) + const { ctx } = createResolver(language) + const state = new ExecutionState() + state.setBlockOutput(start.id, output) + ctx.blockStates = state.getBlockStates() + const workflow: SerializedWorkflow = { + version: '1', + blocks: [start, functionBlock], + connections: [], + loops: {}, + parallels: {}, + } + const resolver = new VariableResolver(workflow, {}, state, { navigatePathAsync }) + const result = await resolver.resolveInputsForFunctionBlock( + ctx, + functionBlock.id, + { code: 'IN=""' }, + functionBlock + ) + + expect(collectSandboxFileMountRefs(result.contextVariables)).toEqual([ + { ...file, key, context: 'workspace' }, + ]) + expect( + replaceSandboxFileMountRefs(result.contextVariables, () => '/tmp/sim/inputs/photo.png') + ).toEqual({ __blockRef_0: '/tmp/sim/inputs/photo.png' }) + expect(result.resolvedInputs.code).not.toContain('') + expect(result.resolvedInputs.code).not.toContain('null') + if (language === 'shell') { + expect(result.resolvedInputs.code).toBe(`IN="\${__blockRef_0}"`) + } + }) +}) + /** Runs one condition expression through the resolver and returns the value the handler receives. */ async function resolveConditionExpression( value: string, diff --git a/apps/sim/lib/mcp/workflow-tool-schema.ts b/apps/sim/lib/mcp/workflow-tool-schema.ts index 31b801d3f1a..86fd97a1641 100644 --- a/apps/sim/lib/mcp/workflow-tool-schema.ts +++ b/apps/sim/lib/mcp/workflow-tool-schema.ts @@ -1,119 +1,9 @@ import { z } from 'zod' +import type { McpToolSchema, McpToolSchemaProperty } from '@/lib/mcp/types' import { normalizeInputFormatValue } from '@/lib/workflows/input-format' +import { generateWorkflowInputShape } from '@/lib/workflows/input-schema' import { isInputDefinitionTrigger } from '@/lib/workflows/triggers/input-definition-triggers' import type { InputFormatField } from '@/lib/workflows/types' -import type { McpToolSchema } from './types' - -/** - * Extended property definition for workflow tool schemas. - * More specific than the generic McpToolSchema properties. - */ -export interface McpToolProperty { - [key: string]: unknown - type: string - description?: string - items?: McpToolProperty - properties?: Record -} - -/** - * Extended MCP tool schema with typed properties (for workflow tool generation). - * Extends the base McpToolSchema with more specific property types. - */ -export interface McpToolInputSchema extends McpToolSchema { - properties: Record -} - -export interface McpToolDefinition { - name: string - description: string - inputSchema: McpToolInputSchema -} - -/** - * File item Zod schema for MCP file inputs. - * This is the single source of truth for file structure. - */ -export const fileItemZodSchema = z.object({ - name: z.string().describe('File name'), - data: z.string().describe('Base64 encoded file content'), - mimeType: z.string().describe('MIME type of the file'), -}) - -/** - * Convert InputFormatField type to Zod schema - */ -function fieldTypeToZod(fieldType: string | undefined, isRequired: boolean): z.ZodTypeAny { - let zodType: z.ZodTypeAny - - switch (fieldType) { - case 'string': - zodType = z.string() - break - case 'number': - zodType = z.number() - break - case 'boolean': - zodType = z.boolean() - break - case 'object': - zodType = z.record(z.string(), z.any()) - break - case 'array': - zodType = z.array(z.any()) - break - case 'files': - zodType = z.array(fileItemZodSchema) - break - default: - zodType = z.string() - } - - return isRequired ? zodType : zodType.optional() -} - -/** - * Generate Zod schema shape from InputFormatField array. - * This is used directly by the MCP server for tool registration. - */ -export function generateToolZodSchema(inputFormat: InputFormatField[]): z.ZodRawShape | undefined { - if (!inputFormat || inputFormat.length === 0) { - return undefined - } - - const shape: Record = {} - - for (const field of inputFormat) { - if (!field.name) continue - - const zodType = fieldTypeToZod(field.type, true) - shape[field.name] = field.name ? zodType.describe(field.name) : zodType - } - - return Object.keys(shape).length > 0 ? shape : undefined -} - -/** - * Map InputFormatField type to JSON Schema type (for database storage) - */ -function mapFieldTypeToJsonSchemaType(fieldType: string | undefined): string { - switch (fieldType) { - case 'string': - return 'string' - case 'number': - return 'number' - case 'boolean': - return 'boolean' - case 'object': - return 'object' - case 'array': - return 'array' - case 'files': - return 'array' - default: - return 'string' - } -} /** * Sanitize a workflow name to be a valid MCP tool name. @@ -136,54 +26,15 @@ export function sanitizeToolName(name: string): string { * This converts the workflow's input format definition to JSON Schema format * that MCP clients can use to understand tool parameters. */ -export function generateToolInputSchema(inputFormat: InputFormatField[]): McpToolInputSchema { - const properties: Record = {} - const required: string[] = [] - - for (const field of inputFormat) { - if (!field.name) continue - - const fieldName = field.name - const fieldType = mapFieldTypeToJsonSchemaType(field.type) - - const property: McpToolProperty = { - type: fieldType, - // Use custom description if provided, otherwise use field name - description: field.description?.trim() || fieldName, - } - - // Handle array types - if (fieldType === 'array') { - if (field.type === 'file[]') { - property.items = { - type: 'object', - properties: { - name: { type: 'string', description: 'File name' }, - url: { type: 'string', description: 'File URL' }, - type: { type: 'string', description: 'MIME type' }, - size: { type: 'number', description: 'File size in bytes' }, - }, - } - // Use custom description if provided, otherwise use default - if (!field.description?.trim()) { - property.description = 'Array of file objects' - } - } else { - property.items = { type: 'string' } - } - } - - properties[fieldName] = property - - // All fields are considered required by default - // (in the future, we could add an optional flag to InputFormatField) - required.push(fieldName) - } - +export function generateToolInputSchema(inputFormat: InputFormatField[]): McpToolSchema { + const schema = z.toJSONSchema(z.object(generateWorkflowInputShape(inputFormat)), { + target: 'draft-07', + io: 'input', + }) return { type: 'object', - properties, - required: required.length > 0 ? required : undefined, + properties: schema.properties as Record, + ...(schema.required?.length ? { required: schema.required } : {}), } } @@ -198,10 +49,10 @@ export function applyDescriptionOverrides( overrides: Record | null | undefined ): Record { if (!overrides || Object.keys(overrides).length === 0) return baseSchema - const baseProperties = baseSchema.properties as Record | undefined + const baseProperties = baseSchema.properties as Record | undefined if (!baseProperties) return baseSchema - const properties: Record = {} + const properties: Record = {} for (const [name, property] of Object.entries(baseProperties)) { const override = overrides[name] properties[name] = @@ -244,7 +95,7 @@ export function extractDescriptionOverrides( | Record | undefined if (!schemaProperties) return overrides - const baseProperties = (baseSchema.properties ?? {}) as Record + const baseProperties = (baseSchema.properties ?? {}) as Record for (const [name, property] of Object.entries(schemaProperties)) { if (!(name in baseProperties)) continue diff --git a/apps/sim/lib/workflows/input-schema.test.ts b/apps/sim/lib/workflows/input-schema.test.ts new file mode 100644 index 00000000000..283cabb2841 --- /dev/null +++ b/apps/sim/lib/workflows/input-schema.test.ts @@ -0,0 +1,120 @@ +import { describe, expect, it } from 'vitest' +import { z } from 'zod' +import { compileMcpToolSchema } from '@/lib/mcp/tool-schema' +import { generateToolInputSchema } from '@/lib/mcp/workflow-tool-schema' +import { generateWorkflowInputShape } from '@/lib/workflows/input-schema' +import type { InputFormatField } from '@/lib/workflows/types' + +const file = { + id: 'image-1', + name: 'photo.png', + url: 'https://storage.example.com/photo.png', + size: 128, + type: 'image/png', + key: 'workspace/workspace-1/photo.png', + context: 'workspace', +} + +const inputFormat: InputFormatField[] = [ + { name: 'input', type: 'string', value: '' }, + { name: 'conversationId', type: 'string', value: '' }, + { name: 'files', type: 'file[]', value: '' }, +] + +const schema = z.object(generateWorkflowInputShape(inputFormat)) + +describe('workflow input schemas', () => { + it('advertises uploaded file objects and validates them without stripping metadata', () => { + const advertised = generateToolInputSchema(inputFormat) + expect(advertised.properties?.files).toMatchObject({ + type: 'array', + items: { + type: 'object', + required: ['id', 'name', 'url', 'size', 'type', 'key'], + properties: { + key: { type: 'string' }, + type: { type: 'string' }, + }, + }, + }) + const input = { input: 'Rotate the image', conversationId: 'conversation-1', files: [file] } + expect(schema.parse(input)).toEqual(input) + }) + + it.each(['', '[]', JSON.stringify([file])])('rejects stringified files: %s', (files) => { + expect(schema.safeParse({ files }).success).toBe(false) + }) + + it('allows empty attachments and omitted fields with configured defaults', () => { + expect(schema.parse({ files: [] })).toEqual({ files: [] }) + expect(schema.parse({})).toEqual({}) + expect(generateToolInputSchema(inputFormat).required).toBeUndefined() + }) + + it.each([ + { name: 'photo.png', data: 'base64', mimeType: 'image/png' }, + { ...file, id: undefined }, + { ...file, name: '' }, + { ...file, type: '' }, + { ...file, size: '128' }, + { ...file, key: undefined }, + { ...file, key: undefined, url: '/api/files/serve/' }, + ])('rejects file input that the Start block cannot consume: %j', (invalidFile) => { + expect(schema.safeParse({ files: [invalidFile] }).success).toBe(false) + }) + + it.each([ + { value: file, valid: true }, + { value: { ...file, key: '' }, valid: false }, + { value: { ...file, key: undefined }, valid: false }, + { + value: { + ...file, + key: undefined, + url: `/api/files/serve/s3/${encodeURIComponent(file.key)}?context=workspace`, + }, + valid: false, + }, + ])('advertises the same storage-key requirement it validates: $valid', ({ value, valid }) => { + const validate = compileMcpToolSchema(generateToolInputSchema(inputFormat)) + const input = { files: [value] } + expect(validate(input)).toBe(valid) + expect(schema.safeParse(input).success).toBe(valid) + }) + + it.each(['__proto__', 'constructor', 'prototype'])( + 'rejects reserved input name %s before schema construction', + (name) => { + const fields: InputFormatField[] = [{ name, type: 'string' }] + expect(() => generateWorkflowInputShape(fields)).toThrow('is reserved') + expect(() => generateToolInputSchema(fields)).toThrow('is reserved') + } + ) + + it('shares required fields, descriptions, and arbitrary array items across schemas', () => { + const fields: InputFormatField[] = [ + { name: ' message ', type: 'string', description: ' User message ' }, + { name: 'count', type: 'number', value: 3 }, + { name: 'enabled', type: 'boolean' }, + { name: 'options', type: 'object' }, + { name: 'items', type: 'array' }, + ] + const advertised = generateToolInputSchema(fields) + expect(advertised.required).toEqual(['message', 'enabled', 'options', 'items']) + expect(advertised.properties?.message).toEqual({ type: 'string', description: 'User message' }) + expect(advertised.properties?.items).toMatchObject({ type: 'array', items: {} }) + const input = { + message: 'hello', + enabled: true, + options: { nested: [1] }, + items: [1, { a: true }], + } + expect(z.object(generateWorkflowInputShape(fields)).parse(input)).toEqual(input) + }) + + it('rejects unsupported type names instead of advertising strings', () => { + expect(() => generateToolInputSchema([{ name: 'files', type: 'files' }])).toThrow( + 'Unsupported workflow input type "files"' + ) + }) +}) diff --git a/apps/sim/lib/workflows/input-schema.ts b/apps/sim/lib/workflows/input-schema.ts new file mode 100644 index 00000000000..29865a6e00f --- /dev/null +++ b/apps/sim/lib/workflows/input-schema.ts @@ -0,0 +1,67 @@ +import { z } from 'zod' +import type { InputFormatField } from '@/lib/workflows/types' +import type { UserFile } from '@/executor/types' +import { isSafeKey } from '@/tools/safe-assign' + +/** + * Canonical uploaded file references for workflow inputs. File ownership is checked + * by the executor against the run's workspace, after input-shape validation. + */ +const workflowInputFileSchema = z + .object({ + id: z.string().min(1, 'File id cannot be empty'), + name: z.string().min(1, 'File name cannot be empty'), + url: z.string().min(1, 'File url cannot be empty'), + size: z.number().nonnegative(), + type: z.string().min(1, 'File MIME type cannot be empty'), + key: z.string().min(1, 'File storage key cannot be empty'), + }) + .passthrough() satisfies z.ZodType< + Pick +> + +function fieldTypeToSchema(type: string | undefined): z.ZodType { + switch (type) { + case undefined: + case 'string': + return z.string() + case 'number': + return z.number() + case 'boolean': + return z.boolean() + case 'object': + return z.record(z.string(), z.unknown()) + case 'array': + return z.array(z.unknown()) + case 'file[]': + return z.array(workflowInputFileSchema) + default: + throw new Error(`Unsupported workflow input type "${type}"`) + } +} + +/** + * Shared by workflow run validation and MCP discovery so the advertised input + * types match execution. The executor supplies configured defaults at run time. + */ +export function generateWorkflowInputShape(inputFormat: InputFormatField[]): z.ZodRawShape { + const shape: Record = {} + + for (const field of inputFormat) { + const name = field.name?.trim() + if (!name) continue + if (!isSafeKey(name)) { + throw new Error(`Workflow input name "${name}" is reserved. Rename this input field.`) + } + + const schema = fieldTypeToSchema(field.type).describe( + field.description?.trim() || + (field.type === 'file[]' + ? 'Array of uploaded file objects. Include each file id, name, url, size, MIME type, and storage key.' + : name) + ) + shape[name] = field.value !== undefined && field.value !== null ? schema.optional() : schema + } + + return shape +} diff --git a/apps/sim/lib/workflows/triggers/run-options.test.ts b/apps/sim/lib/workflows/triggers/run-options.test.ts index 071abf491fc..5625d031fe5 100644 --- a/apps/sim/lib/workflows/triggers/run-options.test.ts +++ b/apps/sim/lib/workflows/triggers/run-options.test.ts @@ -1,11 +1,14 @@ import { describe, expect, it } from 'vitest' import { + resolveTriggerRunOptions, type TriggerInputKind, type TriggerRunOption, validateTriggerInput, } from '@/lib/workflows/triggers/run-options' import { StartBlockPath } from '@/lib/workflows/triggers/triggers' import type { InputFormatField } from '@/lib/workflows/types' +import { buildStartBlockOutput } from '@/executor/utils/start-block' +import type { SerializedBlock } from '@/serializer/types' function makeOption(overrides: Partial): TriggerRunOption { const inputKind: TriggerInputKind = overrides.inputKind ?? 'fields' @@ -25,6 +28,99 @@ function makeOption(overrides: Partial): TriggerRunOption { const fields = (...f: InputFormatField[]): InputFormatField[] => f +describe('file inputs through workflow run options', () => { + const workspaceId = '11111111-1111-4111-8111-111111111111' + const file = { + id: 'file-1', + name: 'photo.png', + url: 'https://storage.example.com/photo.png', + type: 'image/png', + size: 128, + key: `workspace/${workspaceId}/photo.png`, + context: 'workspace', + } + const inputFormat = fields( + { name: 'input', type: 'string', value: '' }, + { name: 'conversationId', type: 'string', value: '' }, + { name: 'files', type: 'file[]', value: '' } + ) + const block = { + type: 'start_trigger', + name: 'Start', + subBlocks: { inputFormat: { value: inputFormat } }, + } + + it('advertises and accepts attachments that survive Start normalization', () => { + const [option] = resolveTriggerRunOptions({ start: block }) + expect(option.inputSchema).toMatchObject({ properties: { files: { type: 'array' } } }) + const workflowInput = { input: 'Rotate this image', conversationId: 'c1', files: [file] } + expect(validateTriggerInput(option, workflowInput)).toEqual({ ok: true }) + + const serialized: SerializedBlock = { + id: 'start', + position: { x: 0, y: 0 }, + config: { tool: 'start_trigger', params: { inputFormat } }, + inputs: {}, + outputs: {}, + enabled: true, + metadata: { id: 'start_trigger', name: 'Start', category: 'triggers' }, + } + const output = buildStartBlockOutput({ + resolution: { blockId: 'start', block: serialized, path: option.path }, + workspaceId, + workflowInput, + }) + expect(output.files).toEqual([file]) + expect(output.input).toBe('Rotate this image') + }) + + it('rejects text attachments before execution with an actionable field error', () => { + const [option] = resolveTriggerRunOptions({ start: block }) + const result = validateTriggerInput(option, { files: JSON.stringify([file]) }) + expect(result.ok).toBe(false) + expect(result.error).toContain('files') + expect(result.error).toContain('expected array') + }) + + it('generates valid mock input from empty file defaults', () => { + const [option] = resolveTriggerRunOptions({ start: block }) + expect(option.mockPayload).toMatchObject({ files: [] }) + expect(validateTriggerInput(option, option.mockPayload)).toEqual({ ok: true }) + }) + + it('preserves uploaded files stored as JSON in editor defaults', () => { + const [option] = resolveTriggerRunOptions({ + start: { + ...block, + subBlocks: { + inputFormat: { + value: [{ name: 'files', type: 'file[]', value: JSON.stringify([file]) }], + }, + }, + }, + }) + expect(option.mockPayload).toEqual({ files: [file] }) + expect(validateTriggerInput(option, option.mockPayload)).toEqual({ ok: true }) + }) + + it('normalizes an editor file URL to canonical metadata in the sample payload', () => { + const { key, ...editorFile } = file + editorFile.url = `/api/files/serve/s3/${encodeURIComponent(key)}?context=workspace` + const [option] = resolveTriggerRunOptions({ + start: { + ...block, + subBlocks: { + inputFormat: { + value: [{ name: 'files', type: 'file[]', value: JSON.stringify([editorFile]) }], + }, + }, + }, + }) + expect(option.mockPayload).toEqual({ files: [{ ...editorFile, key }] }) + expect(validateTriggerInput(option, option.mockPayload)).toEqual({ ok: true }) + }) +}) + describe('validateTriggerInput', () => { describe('fields', () => { it('accepts input that provides all declared fields with correct types', () => { diff --git a/apps/sim/lib/workflows/triggers/run-options.ts b/apps/sim/lib/workflows/triggers/run-options.ts index c8d79d588b6..53bf451c8cf 100644 --- a/apps/sim/lib/workflows/triggers/run-options.ts +++ b/apps/sim/lib/workflows/triggers/run-options.ts @@ -1,7 +1,9 @@ import { isRecordLike } from '@sim/utils/object' import { z } from 'zod' -import { generateToolInputSchema, generateToolZodSchema } from '@/lib/mcp/workflow-tool-schema' -import { normalizeInputFormatValue } from '@/lib/workflows/input-format' +import { generateToolInputSchema } from '@/lib/mcp/workflow-tool-schema' +import { parseInternalFileUrl } from '@/lib/uploads/utils/file-utils' +import { normalizeInputFormatValue, parseInputFormatFiles } from '@/lib/workflows/input-format' +import { generateWorkflowInputShape } from '@/lib/workflows/input-schema' import { extractTriggerMockPayload, selectBestTrigger, @@ -134,9 +136,6 @@ function mockValueForType(type: string | undefined, name: string): unknown { return [] case 'object': return {} - case 'files': - case 'file[]': - return [] default: return `mock_${name}` } @@ -145,11 +144,19 @@ function mockValueForType(type: string | undefined, name: string): unknown { function buildFieldsSample(inputFormat: InputFormatField[]): Record { const sample: Record = {} for (const field of inputFormat) { - if (!field.name) continue - sample[field.name] = + const name = field.name?.trim() + if (!name) continue + if (field.type === 'file[]') { + sample[name] = parseInputFormatFiles(field.value).map((file) => ({ + ...file, + key: file.key || parseInternalFileUrl(file.url).key, + })) + continue + } + sample[name] = field.value !== undefined && field.value !== null ? coerceValue(field.type, field.value) - : mockValueForType(field.type, field.name) + : mockValueForType(field.type, name) } return sample } @@ -347,8 +354,8 @@ export function validateTriggerInput( return { ok: true } } default: { - const baseShape = generateToolZodSchema(option.inputFormat) - if (!baseShape) { + const shape = generateWorkflowInputShape(option.inputFormat) + if (Object.keys(shape).length === 0) { // Trigger declares no input fields — accept an object (including {}). if (input === undefined || input === null) return { ok: true } if (!isRecordLike(input)) { @@ -360,17 +367,6 @@ export function validateTriggerInput( return { ok: true } } - // A field with an author-configured default is optional: the executor fills - // the default when it's omitted (deriveInputFromFormat), so requiring it - // would reject a run the workflow itself accepts. - const shape: Record = {} - for (const [name, baseType] of Object.entries(baseShape)) { - const zodType = baseType as z.ZodTypeAny - const field = option.inputFormat.find((f) => f.name === name) - const hasDefault = field?.value !== undefined && field?.value !== null - shape[name] = hasDefault ? zodType.optional() : zodType - } - // UNIFIED start blocks pass arbitrary keys through to their output, so // unknown keys are valid there; other trigger kinds only consume declared // fields, so unknown keys signal a mistake and are rejected.