From c3ce5bf67d6169b0ca1bb8b495c6abe8433215d3 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Sat, 14 Jun 2025 14:43:38 -0700 Subject: [PATCH 1/4] added option to grab raw gmail from gmail polling service --- .../webhook/components/providers/gmail.tsx | 126 ++++++++++++++---- .../webhook/components/webhook-modal.tsx | 18 ++- .../sub-block/components/webhook/webhook.tsx | 7 + .../sim/lib/webhooks/gmail-polling-service.ts | 16 ++- apps/sim/lib/webhooks/utils.ts | 1 + 5 files changed, 139 insertions(+), 29 deletions(-) diff --git a/apps/sim/app/w/[id]/components/workflow-block/components/sub-block/components/webhook/components/providers/gmail.tsx b/apps/sim/app/w/[id]/components/workflow-block/components/sub-block/components/webhook/components/providers/gmail.tsx index 0760ad104ad..a23eb809d83 100644 --- a/apps/sim/app/w/[id]/components/workflow-block/components/sub-block/components/webhook/components/providers/gmail.tsx +++ b/apps/sim/app/w/[id]/components/workflow-block/components/sub-block/components/webhook/components/providers/gmail.tsx @@ -25,6 +25,7 @@ const TOOLTIPS = { labels: 'Select which email labels to monitor.', labelFilter: 'Choose whether to include or exclude the selected labels.', markAsRead: 'Emails will be marked as read after being processed by your workflow.', + includeRawEmail: 'Include the complete, unprocessed email data from Gmail.', } const FALLBACK_GMAIL_LABELS = [ @@ -55,31 +56,74 @@ const formatLabelName = (label: GmailLabel): string => { return formattedName } -const exampleEmailEvent = { - email: { - id: '18e0ffabd5b5a0f4', - threadId: '18e0ffabd5b5a0f4', - subject: 'Monthly Report - April 2025', - from: 'sender@example.com', - to: 'recipient@example.com', - cc: 'team@example.com', - date: '2025-05-10T10:15:23.000Z', - bodyText: - 'Hello,\n\nPlease find attached the monthly report for April 2025.\n\nBest regards,\nSender', - bodyHtml: - '

Hello,

Please find attached the monthly report for April 2025.

Best regards,
Sender

', - snippet: 'Hello, Please find attached the monthly report for April 2025...', - labels: ['INBOX', 'IMPORTANT'], - hasAttachments: true, - attachments: [ - { - filename: 'report-april-2025.pdf', - mimeType: 'application/pdf', - size: 2048576, +const getExampleEmailEvent = (includeRawEmail: boolean) => { + const baseExample = { + email: { + id: '18e0ffabd5b5a0f4', + threadId: '18e0ffabd5b5a0f4', + subject: 'Monthly Report - April 2025', + from: 'sender@example.com', + to: 'recipient@example.com', + cc: 'team@example.com', + date: '2025-05-10T10:15:23.000Z', + bodyText: + 'Hello,\n\nPlease find attached the monthly report for April 2025.\n\nBest regards,\nSender', + bodyHtml: + '

Hello,

Please find attached the monthly report for April 2025.

Best regards,
Sender

', + labels: ['INBOX', 'IMPORTANT'], + hasAttachments: true, + attachments: [ + { + filename: 'report-april-2025.pdf', + mimeType: 'application/pdf', + size: 2048576, + }, + ], + }, + timestamp: '2025-05-10T10:15:30.123Z', + } + + if (includeRawEmail) { + return { + ...baseExample, + rawEmail: { + id: '18e0ffabd5b5a0f4', + threadId: '18e0ffabd5b5a0f4', + labelIds: ['INBOX', 'IMPORTANT'], + snippet: 'Hello, Please find attached the monthly report...', + historyId: '123456', + internalDate: '1715337323000', + payload: { + partId: '', + mimeType: 'multipart/mixed', + filename: '', + headers: [ + { name: 'From', value: 'sender@example.com' }, + { name: 'To', value: 'recipient@example.com' }, + { name: 'Subject', value: 'Monthly Report - April 2025' }, + { name: 'Date', value: 'Fri, 10 May 2025 10:15:23 +0000' }, + { name: 'Message-ID', value: '' }, + ], + body: { size: 0 }, + parts: [ + { + partId: '0', + mimeType: 'text/plain', + filename: '', + headers: [{ name: 'Content-Type', value: 'text/plain; charset=UTF-8' }], + body: { + size: 85, + data: 'SGVsbG8sDQoNClBsZWFzZSBmaW5kIGF0dGFjaGVkIHRoZSBtb250aGx5IHJlcG9ydA==', + }, + }, + ], + }, + sizeEstimate: 4156, }, - ], - }, - timestamp: '2025-05-10T10:15:30.123Z', + } + } + + return baseExample } interface GmailConfigProps { @@ -89,6 +133,8 @@ interface GmailConfigProps { setLabelFilterBehavior: (behavior: 'INCLUDE' | 'EXCLUDE') => void markAsRead?: boolean setMarkAsRead?: (markAsRead: boolean) => void + includeRawEmail?: boolean + setIncludeRawEmail?: (includeRawEmail: boolean) => void } export function GmailConfig({ @@ -98,6 +144,8 @@ export function GmailConfig({ setLabelFilterBehavior, markAsRead = false, setMarkAsRead = () => {}, + includeRawEmail = false, + setIncludeRawEmail = () => {}, }: GmailConfigProps) { const [labels, setLabels] = useState([]) const [isLoadingLabels, setIsLoadingLabels] = useState(false) @@ -286,6 +334,34 @@ export function GmailConfig({ + +
+
+ setIncludeRawEmail(checked as boolean)} + /> + + + + + + +

{TOOLTIPS.includeRawEmail}

+
+
+
+
@@ -296,7 +372,7 @@ export function GmailConfig({ title='Gmail Event Payload Example' >
- +
diff --git a/apps/sim/app/w/[id]/components/workflow-block/components/sub-block/components/webhook/components/webhook-modal.tsx b/apps/sim/app/w/[id]/components/workflow-block/components/sub-block/components/webhook/components/webhook-modal.tsx index 2c2690aad9f..0c34ba2bab1 100644 --- a/apps/sim/app/w/[id]/components/workflow-block/components/sub-block/components/webhook/components/webhook-modal.tsx +++ b/apps/sim/app/w/[id]/components/workflow-block/components/sub-block/components/webhook/components/webhook-modal.tsx @@ -106,11 +106,13 @@ export function WebhookModal({ selectedLabels: ['INBOX'] as string[], labelFilterBehavior: 'INCLUDE', markAsRead: false, + includeRawEmail: false, }) const [selectedLabels, setSelectedLabels] = useState(['INBOX']) const [labelFilterBehavior, setLabelFilterBehavior] = useState<'INCLUDE' | 'EXCLUDE'>('INCLUDE') const [markAsRead, setMarkAsRead] = useState(false) + const [includeRawEmail, setIncludeRawEmail] = useState(false) // Get the current provider configuration const _provider = WEBHOOK_PROVIDERS[webhookProvider] || WEBHOOK_PROVIDERS.generic @@ -249,6 +251,14 @@ export function WebhookModal({ setMarkAsRead(config.markAsRead) setOriginalValues((prev) => ({ ...prev, markAsRead: config.markAsRead })) } + + if (config.includeRawEmail !== undefined) { + setIncludeRawEmail(config.includeRawEmail) + setOriginalValues((prev) => ({ + ...prev, + includeRawEmail: config.includeRawEmail, + })) + } } } } @@ -292,7 +302,8 @@ export function WebhookModal({ (!selectedLabels.every((label) => originalValues.selectedLabels.includes(label)) || !originalValues.selectedLabels.every((label) => selectedLabels.includes(label)) || labelFilterBehavior !== originalValues.labelFilterBehavior || - markAsRead !== originalValues.markAsRead)) + markAsRead !== originalValues.markAsRead || + includeRawEmail !== originalValues.includeRawEmail)) setHasUnsavedChanges(hasChanges) }, [ @@ -315,6 +326,7 @@ export function WebhookModal({ selectedLabels, labelFilterBehavior, markAsRead, + includeRawEmail, ]) // Validate required fields for current provider @@ -389,6 +401,7 @@ export function WebhookModal({ labelIds: selectedLabels, labelFilterBehavior, markAsRead, + includeRawEmail, maxEmailsPerPoll: 25, } case 'generic': { @@ -472,6 +485,7 @@ export function WebhookModal({ selectedLabels, labelFilterBehavior, markAsRead, + includeRawEmail, }) setHasUnsavedChanges(false) setTestResult({ @@ -641,6 +655,8 @@ export function WebhookModal({ setLabelFilterBehavior={setLabelFilterBehavior} markAsRead={markAsRead} setMarkAsRead={setMarkAsRead} + includeRawEmail={includeRawEmail} + setIncludeRawEmail={setIncludeRawEmail} /> ) case 'discord': diff --git a/apps/sim/app/w/[id]/components/workflow-block/components/sub-block/components/webhook/webhook.tsx b/apps/sim/app/w/[id]/components/workflow-block/components/sub-block/components/webhook/webhook.tsx index 79c4ecc2b49..7f844ca8006 100644 --- a/apps/sim/app/w/[id]/components/workflow-block/components/sub-block/components/webhook/webhook.tsx +++ b/apps/sim/app/w/[id]/components/workflow-block/components/sub-block/components/webhook/webhook.tsx @@ -69,6 +69,7 @@ export interface GmailConfig { labelIds?: string[] labelFilterBehavior?: 'INCLUDE' | 'EXCLUDE' markAsRead?: boolean + includeRawEmail?: boolean maxEmailsPerPoll?: number } @@ -145,6 +146,12 @@ export const WEBHOOK_PROVIDERS: { [key: string]: WebhookProvider } = { defaultValue: false, description: 'Mark emails as read after processing.', }, + includeRawEmail: { + type: 'boolean', + label: 'Include Raw Email Data', + defaultValue: false, + description: 'Include the complete, unprocessed email data from Gmail.', + }, maxEmailsPerPoll: { type: 'string', label: 'Max Emails Per Poll', diff --git a/apps/sim/lib/webhooks/gmail-polling-service.ts b/apps/sim/lib/webhooks/gmail-polling-service.ts index 066fcbbd9b5..21510e4ca4f 100644 --- a/apps/sim/lib/webhooks/gmail-polling-service.ts +++ b/apps/sim/lib/webhooks/gmail-polling-service.ts @@ -18,6 +18,7 @@ interface GmailWebhookConfig { historyId?: string processedEmailIds?: string[] pollingInterval?: number + includeRawEmail?: boolean } interface GmailEmail { @@ -45,6 +46,12 @@ export interface SimplifiedEmail { attachments: Array<{ filename: string; mimeType: string; size: number }> } +export interface GmailWebhookPayload { + email: SimplifiedEmail + timestamp: string + rawEmail?: GmailEmail // Only included when includeRawEmail is true +} + export async function pollGmailWebhooks() { logger.info('Starting Gmail webhook polling') @@ -590,13 +597,16 @@ async function processEmails( attachments: attachments, } - // Prepare webhook payload with simplified email - const payload = { + // Prepare webhook payload with simplified email and optionally raw email + const payload: GmailWebhookPayload = { email: simplifiedEmail, timestamp: new Date().toISOString(), + ...(config.includeRawEmail ? { rawEmail: email } : {}), } - logger.debug(`[${requestId}] Sending simplified email payload for ${email.id}`) + logger.debug( + `[${requestId}] Sending ${config.includeRawEmail ? 'simplified + raw' : 'simplified'} email payload for ${email.id}` + ) // Trigger the webhook const webhookUrl = `${getBaseUrl()}/api/webhooks/trigger/${webhookData.path}` diff --git a/apps/sim/lib/webhooks/utils.ts b/apps/sim/lib/webhooks/utils.ts index a4cdc9915b8..42369f973fd 100644 --- a/apps/sim/lib/webhooks/utils.ts +++ b/apps/sim/lib/webhooks/utils.ts @@ -1504,6 +1504,7 @@ export async function configureGmailPolling( maxEmailsPerPoll, pollingInterval, markAsRead: providerConfig.markAsRead || false, + includeRawEmail: providerConfig.includeRawEmail || false, labelIds: providerConfig.labelIds || ['INBOX'], labelFilterBehavior: providerConfig.labelFilterBehavior || 'INCLUDE', lastCheckedTimestamp: now.toISOString(), From 93b15e991d0505da4f88be90b395a0d0b0f49803 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Sat, 14 Jun 2025 15:30:52 -0700 Subject: [PATCH 2/4] safe json parse for function block execution to prevent vars in raw email from being resolved as sim studio vars --- .../app/api/function/execute/route.test.ts | 545 ++++++++++++++++++ apps/sim/app/api/function/execute/route.ts | 73 ++- 2 files changed, 606 insertions(+), 12 deletions(-) create mode 100644 apps/sim/app/api/function/execute/route.test.ts diff --git a/apps/sim/app/api/function/execute/route.test.ts b/apps/sim/app/api/function/execute/route.test.ts new file mode 100644 index 00000000000..4e28acf6cd2 --- /dev/null +++ b/apps/sim/app/api/function/execute/route.test.ts @@ -0,0 +1,545 @@ +import { NextRequest } from 'next/server' +/** + * Tests for function execution API route + * + * @vitest-environment node + */ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { createMockRequest } from '@/app/api/__test-utils__/utils' + +// Mock implementations (shared across all test suites) +const mockFreestyleExecuteScript = vi.fn() +const mockCreateContext = vi.fn() +const mockScript = vi.fn() +const mockRunInContext = vi.fn() +const mockLogger = { + info: vi.fn(), + error: vi.fn(), + warn: vi.fn(), + debug: vi.fn(), +} + +describe('Function Execute API Route', () => { + beforeEach(() => { + vi.resetModules() + vi.resetAllMocks() + + vi.doMock('vm', () => ({ + createContext: mockCreateContext, + Script: vi.fn().mockImplementation(() => ({ + runInContext: mockRunInContext, + })), + })) + + vi.doMock('freestyle-sandboxes', () => ({ + FreestyleSandboxes: vi.fn().mockImplementation(() => ({ + executeScript: mockFreestyleExecuteScript, + })), + })) + + vi.doMock('@/lib/env', () => ({ + env: { + FREESTYLE_API_KEY: 'test-freestyle-key', + }, + })) + + vi.doMock('@/lib/logs/console-logger', () => ({ + createLogger: vi.fn().mockReturnValue(mockLogger), + })) + + mockFreestyleExecuteScript.mockResolvedValue({ + result: 'freestyle success', + logs: [], + }) + + mockRunInContext.mockResolvedValue('vm success') + mockCreateContext.mockReturnValue({}) + }) + + afterEach(() => { + vi.clearAllMocks() + }) + + describe('Basic Function Execution', () => { + it('should execute simple JavaScript code successfully', async () => { + const req = createMockRequest('POST', { + code: 'return "Hello World"', + timeout: 5000, + }) + + const { POST } = await import('./route') + const response = await POST(req) + const data = await response.json() + + expect(response.status).toBe(200) + expect(data.success).toBe(true) + expect(data.output).toHaveProperty('result') + expect(data.output).toHaveProperty('executionTime') + }) + + it('should handle missing code parameter', async () => { + const req = createMockRequest('POST', { + timeout: 5000, + }) + + const { POST } = await import('./route') + const response = await POST(req) + const data = await response.json() + + expect(response.status).toBe(500) + expect(data.success).toBe(false) + expect(data).toHaveProperty('error') + }) + + it('should use default timeout when not provided', async () => { + const req = createMockRequest('POST', { + code: 'return "test"', + }) + + const { POST } = await import('./route') + const response = await POST(req) + + expect(response.status).toBe(200) + expect(mockLogger.info).toHaveBeenCalledWith( + expect.stringMatching(/\[.*\] Function execution request/), + expect.objectContaining({ + timeout: 5000, // default timeout + }) + ) + }) + }) + + describe('Template Variable Resolution', () => { + it('should resolve environment variables with {{var_name}} syntax', async () => { + const req = createMockRequest('POST', { + code: 'return {{API_KEY}}', + envVars: { + API_KEY: 'secret-key-123', + }, + }) + + const { POST } = await import('./route') + const response = await POST(req) + + expect(response.status).toBe(200) + // The code should be resolved to: return "secret-key-123" + }) + + it('should resolve tag variables with syntax', async () => { + const req = createMockRequest('POST', { + code: 'return ', + params: { + email: { id: '123', subject: 'Test Email' }, + }, + }) + + const { POST } = await import('./route') + const response = await POST(req) + + expect(response.status).toBe(200) + // The code should be resolved with the email object + }) + + it('should NOT treat email addresses as template variables', async () => { + const req = createMockRequest('POST', { + code: 'return "Email sent to user"', + params: { + email: { + from: 'Waleed Latif ', + to: 'User ', + }, + }, + }) + + const { POST } = await import('./route') + const response = await POST(req) + + expect(response.status).toBe(200) + // Should not try to replace as a template variable + }) + + it('should only match valid variable names in angle brackets', async () => { + const req = createMockRequest('POST', { + code: 'return + "" + ', + params: { + validVar: 'hello', + another_valid: 'world', + }, + }) + + const { POST } = await import('./route') + const response = await POST(req) + + expect(response.status).toBe(200) + // Should replace and but not + }) + }) + + describe('Gmail Email Data Handling', () => { + it('should handle Gmail webhook data with email addresses containing angle brackets', async () => { + const gmailData = { + email: { + id: '123', + from: 'Waleed Latif ', + to: 'User ', + subject: 'Test Email', + bodyText: 'Hello world', + }, + rawEmail: { + id: '123', + payload: { + headers: [ + { name: 'From', value: 'Waleed Latif ' }, + { name: 'To', value: 'User ' }, + ], + }, + }, + } + + const req = createMockRequest('POST', { + code: 'return ', + params: gmailData, + }) + + const { POST } = await import('./route') + const response = await POST(req) + + expect(response.status).toBe(200) + const data = await response.json() + expect(data.success).toBe(true) + }) + + it('should properly serialize complex email objects with special characters', async () => { + const complexEmailData = { + email: { + from: 'Test User ', + bodyHtml: '
HTML content with "quotes" and \'apostrophes\'
', + bodyText: 'Text with\nnewlines\tand\ttabs', + }, + } + + const req = createMockRequest('POST', { + code: 'return ', + params: complexEmailData, + }) + + const { POST } = await import('./route') + const response = await POST(req) + + expect(response.status).toBe(200) + }) + }) + + describe('Freestyle Execution', () => { + it('should use Freestyle when API key is available', async () => { + const req = createMockRequest('POST', { + code: 'return "freestyle test"', + }) + + const { POST } = await import('./route') + await POST(req) + + expect(mockFreestyleExecuteScript).toHaveBeenCalled() + expect(mockLogger.info).toHaveBeenCalledWith( + expect.stringMatching(/\[.*\] Using Freestyle for code execution/) + ) + }) + + it('should handle Freestyle errors and fallback to VM', async () => { + mockFreestyleExecuteScript.mockRejectedValueOnce(new Error('Freestyle API error')) + + const req = createMockRequest('POST', { + code: 'return "fallback test"', + }) + + const { POST } = await import('./route') + const response = await POST(req) + + expect(mockFreestyleExecuteScript).toHaveBeenCalled() + expect(mockRunInContext).toHaveBeenCalled() + expect(mockLogger.error).toHaveBeenCalledWith( + expect.stringMatching(/\[.*\] Freestyle API call failed, falling back to VM:/), + expect.any(Object) + ) + }) + + it('should handle Freestyle script errors', async () => { + mockFreestyleExecuteScript.mockResolvedValueOnce({ + result: null, + logs: [{ type: 'error', message: 'ReferenceError: undefined variable' }], + }) + + const req = createMockRequest('POST', { + code: 'return undefinedVariable', + }) + + const { POST } = await import('./route') + const response = await POST(req) + + expect(response.status).toBe(500) + const data = await response.json() + expect(data.success).toBe(false) + }) + }) + + describe('VM Execution', () => { + it('should use VM when Freestyle API key is not available', async () => { + // Mock no Freestyle API key + vi.doMock('@/lib/env', () => ({ + env: { + FREESTYLE_API_KEY: undefined, + }, + })) + + const req = createMockRequest('POST', { + code: 'return "vm test"', + }) + + const { POST } = await import('./route') + await POST(req) + + expect(mockFreestyleExecuteScript).not.toHaveBeenCalled() + expect(mockRunInContext).toHaveBeenCalled() + expect(mockLogger.info).toHaveBeenCalledWith( + expect.stringMatching( + /\[.*\] Using VM for code execution \(no Freestyle API key available\)/ + ) + ) + }) + + it('should handle VM execution errors', async () => { + // Mock no Freestyle API key so it uses VM + vi.doMock('@/lib/env', () => ({ + env: { + FREESTYLE_API_KEY: undefined, + }, + })) + + mockRunInContext.mockRejectedValueOnce(new Error('VM execution error')) + + const req = createMockRequest('POST', { + code: 'return invalidCode(', + }) + + const { POST } = await import('./route') + const response = await POST(req) + + expect(response.status).toBe(500) + const data = await response.json() + expect(data.success).toBe(false) + expect(data.error).toContain('VM execution error') + }) + }) + + describe('Custom Tools', () => { + it('should handle custom tool execution with direct parameter access', async () => { + const req = createMockRequest('POST', { + code: 'return location + " weather is sunny"', + params: { + location: 'San Francisco', + }, + isCustomTool: true, + }) + + const { POST } = await import('./route') + const response = await POST(req) + + expect(response.status).toBe(200) + // For custom tools, parameters should be directly accessible as variables + }) + }) + + describe('Security and Edge Cases', () => { + it('should handle malformed JSON in request body', async () => { + const req = new NextRequest('http://localhost:3000/api/function/execute', { + method: 'POST', + body: 'invalid json{', + headers: { 'Content-Type': 'application/json' }, + }) + + const { POST } = await import('./route') + const response = await POST(req) + + expect(response.status).toBe(500) + }) + + it('should handle timeout parameter', async () => { + const req = createMockRequest('POST', { + code: 'return "test"', + timeout: 10000, + }) + + const { POST } = await import('./route') + await POST(req) + + expect(mockLogger.info).toHaveBeenCalledWith( + expect.stringMatching(/\[.*\] Function execution request/), + expect.objectContaining({ + timeout: 10000, + }) + ) + }) + + it('should handle empty parameters object', async () => { + const req = createMockRequest('POST', { + code: 'return "no params"', + params: {}, + }) + + const { POST } = await import('./route') + const response = await POST(req) + + expect(response.status).toBe(200) + }) + + it('should exclude _context from execution parameters', async () => { + const req = createMockRequest('POST', { + code: 'return "test"', + params: { + data: 'valid', + _context: { workflowId: 'should-be-excluded' }, + }, + }) + + const { POST } = await import('./route') + await POST(req) + + // _context should be removed from executionParams + expect(mockLogger.info).toHaveBeenCalledWith( + expect.stringMatching(/\[.*\] Function execution request/), + expect.objectContaining({ + paramsCount: 1, // Only 'data' should be counted, not '_context' + }) + ) + }) + }) + + describe('Utility Functions', () => { + it('should properly escape regex special characters', async () => { + // This tests the escapeRegExp function indirectly + const req = createMockRequest('POST', { + code: 'return {{special.chars+*?}}', + envVars: { + 'special.chars+*?': 'escaped-value', + }, + }) + + const { POST } = await import('./route') + const response = await POST(req) + + expect(response.status).toBe(200) + // Should handle special regex characters in variable names + }) + + it('should handle JSON serialization edge cases', async () => { + // Test with complex but not circular data first + const req = createMockRequest('POST', { + code: 'return ', + params: { + complexData: { + special: 'chars"with\'quotes', + unicode: '🎉 Unicode content', + nested: { + deep: { + value: 'test', + }, + }, + }, + }, + }) + + const { POST } = await import('./route') + const response = await POST(req) + + // Should handle complex data gracefully + expect(response.status).toBe(200) + }) + }) +}) + +describe('Function Execute API - Template Variable Edge Cases', () => { + beforeEach(() => { + vi.resetModules() + vi.resetAllMocks() + + vi.doMock('@/lib/logs/console-logger', () => ({ + createLogger: vi.fn().mockReturnValue(mockLogger), + })) + + vi.doMock('@/lib/env', () => ({ + env: { + FREESTYLE_API_KEY: 'test-freestyle-key', + }, + })) + + vi.doMock('vm', () => ({ + createContext: mockCreateContext, + Script: vi.fn().mockImplementation(() => ({ + runInContext: mockRunInContext, + })), + })) + + vi.doMock('freestyle-sandboxes', () => ({ + FreestyleSandboxes: vi.fn().mockImplementation(() => ({ + executeScript: mockFreestyleExecuteScript, + })), + })) + + mockFreestyleExecuteScript.mockResolvedValue({ + result: 'freestyle success', + logs: [], + }) + + mockRunInContext.mockResolvedValue('vm success') + mockCreateContext.mockReturnValue({}) + }) + + it('should handle nested template variables', async () => { + const req = createMockRequest('POST', { + code: 'return {{outer}} + ', + envVars: { + outer: 'environment-value', + }, + params: { + inner: 'param-value', + }, + }) + + const { POST } = await import('./route') + const response = await POST(req) + + expect(response.status).toBe(200) + }) + + it('should prioritize environment variables over params for {{}} syntax', async () => { + const req = createMockRequest('POST', { + code: 'return {{conflictVar}}', + envVars: { + conflictVar: 'env-wins', + }, + params: { + conflictVar: 'param-loses', + }, + }) + + const { POST } = await import('./route') + const response = await POST(req) + + expect(response.status).toBe(200) + // Environment variable should take precedence + }) + + it('should handle missing template variables gracefully', async () => { + const req = createMockRequest('POST', { + code: 'return {{nonexistent}} + ', + envVars: {}, + params: {}, + }) + + const { POST } = await import('./route') + const response = await POST(req) + + expect(response.status).toBe(200) + // Should replace with empty strings for missing variables + }) +}) diff --git a/apps/sim/app/api/function/execute/route.ts b/apps/sim/app/api/function/execute/route.ts index 1d538ecc19d..0d8690db39d 100644 --- a/apps/sim/app/api/function/execute/route.ts +++ b/apps/sim/app/api/function/execute/route.ts @@ -16,6 +16,39 @@ const logger = createLogger('FunctionExecuteAPI') * @param envVars - Environment variables from the workflow * @returns Resolved code */ +/** + * Safely serialize a value to JSON string with proper escaping + * This prevents JavaScript syntax errors when the serialized data is injected into code + */ +function safeJSONStringify(value: any): string { + try { + // Use JSON.stringify with proper escaping + // The key is to let JSON.stringify handle the escaping properly + return JSON.stringify(value) + } catch (error) { + // If JSON.stringify fails (e.g., circular references), return a safe fallback + try { + // Try to create a safe representation by removing circular references + const seen = new WeakSet() + const cleanValue = JSON.parse( + JSON.stringify(value, (key, val) => { + if (typeof val === 'object' && val !== null) { + if (seen.has(val)) { + return '[Circular Reference]' + } + seen.add(val) + } + return val + }) + ) + return JSON.stringify(cleanValue) + } catch { + // If that also fails, return a safe string representation + return JSON.stringify(String(value)) + } + } +} + function resolveCodeVariables( code: string, params: Record, @@ -29,21 +62,34 @@ function resolveCodeVariables( const varName = match.slice(2, -2).trim() // Priority: 1. Environment variables from workflow, 2. Params const varValue = envVars[varName] || params[varName] || '' - // Wrap the value in quotes to ensure it's treated as a string literal - resolvedCode = resolvedCode.replace(match, JSON.stringify(varValue)) + // Use safe JSON stringify to prevent syntax errors + resolvedCode = resolvedCode.replace( + new RegExp(escapeRegExp(match), 'g'), + safeJSONStringify(varValue) + ) } // Resolve tags with syntax - const tagMatches = resolvedCode.match(/<([^>]+)>/g) || [] + const tagMatches = resolvedCode.match(/<([a-zA-Z_][a-zA-Z0-9_]*)>/g) || [] for (const match of tagMatches) { const tagName = match.slice(1, -1).trim() const tagValue = params[tagName] || '' - resolvedCode = resolvedCode.replace(match, JSON.stringify(tagValue)) + resolvedCode = resolvedCode.replace( + new RegExp(escapeRegExp(match), 'g'), + safeJSONStringify(tagValue) + ) } return resolvedCode } +/** + * Escape special regex characters in a string + */ +function escapeRegExp(string: string): string { + return string.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') +} + export async function POST(req: NextRequest) { const requestId = crypto.randomUUID().slice(0, 8) const startTime = Date.now() @@ -61,18 +107,18 @@ export async function POST(req: NextRequest) { isCustomTool = false, } = body + // Extract internal parameters that shouldn't be passed to the execution context + const executionParams = { ...params } + executionParams._context = undefined + logger.info(`[${requestId}] Function execution request`, { hasCode: !!code, - paramsCount: Object.keys(params).length, + paramsCount: Object.keys(executionParams).length, timeout, workflowId, isCustomTool, }) - // Extract internal parameters that shouldn't be passed to the execution context - const executionParams = { ...params } - executionParams._context = undefined - // Resolve variables in the code with workflow environment variables const resolvedCode = resolveCodeVariables(code, executionParams, envVars) @@ -115,7 +161,7 @@ export async function POST(req: NextRequest) { ? `export default async () => { // For custom tools, directly declare parameters as variables ${Object.entries(executionParams) - .map(([key, value]) => `const ${key} = ${JSON.stringify(value)};`) + .map(([key, value]) => `const ${key} = ${safeJSONStringify(value)};`) .join('\n ')} ${resolvedCode} }` @@ -152,7 +198,10 @@ export async function POST(req: NextRequest) { errorMessage, stdout, }) - throw errorMessage + // Create a proper Error object to be caught by the outer handler + const scriptError = new Error(errorMessage) + scriptError.name = 'FreestyleScriptError' + throw scriptError } // If no errors, execution was successful @@ -163,7 +212,7 @@ export async function POST(req: NextRequest) { }) } catch (error: any) { // Check if the error came from our explicit throw above due to script errors - if (error instanceof Error) { + if (error.name === 'FreestyleScriptError') { throw error // Re-throw to be caught by the outer handler } From e7152df4a4b76511d69d1cfcc832d1822c3aea89 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Sat, 14 Jun 2025 16:38:35 -0700 Subject: [PATCH 3/4] added tests --- .../app/api/function/execute/route.test.ts | 48 +++++++++---------- 1 file changed, 24 insertions(+), 24 deletions(-) diff --git a/apps/sim/app/api/function/execute/route.test.ts b/apps/sim/app/api/function/execute/route.test.ts index 4e28acf6cd2..9b0b2e8988e 100644 --- a/apps/sim/app/api/function/execute/route.test.ts +++ b/apps/sim/app/api/function/execute/route.test.ts @@ -7,10 +7,8 @@ import { NextRequest } from 'next/server' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { createMockRequest } from '@/app/api/__test-utils__/utils' -// Mock implementations (shared across all test suites) const mockFreestyleExecuteScript = vi.fn() const mockCreateContext = vi.fn() -const mockScript = vi.fn() const mockRunInContext = vi.fn() const mockLogger = { info: vi.fn(), @@ -391,27 +389,6 @@ describe('Function Execute API Route', () => { expect(response.status).toBe(200) }) - - it('should exclude _context from execution parameters', async () => { - const req = createMockRequest('POST', { - code: 'return "test"', - params: { - data: 'valid', - _context: { workflowId: 'should-be-excluded' }, - }, - }) - - const { POST } = await import('./route') - await POST(req) - - // _context should be removed from executionParams - expect(mockLogger.info).toHaveBeenCalledWith( - expect.stringMatching(/\[.*\] Function execution request/), - expect.objectContaining({ - paramsCount: 1, // Only 'data' should be counted, not '_context' - }) - ) - }) }) describe('Utility Functions', () => { @@ -495,6 +472,11 @@ describe('Function Execute API - Template Variable Edge Cases', () => { }) it('should handle nested template variables', async () => { + mockFreestyleExecuteScript.mockResolvedValueOnce({ + result: 'environment-valueparam-value', + logs: [], + }) + const req = createMockRequest('POST', { code: 'return {{outer}} + ', envVars: { @@ -507,11 +489,19 @@ describe('Function Execute API - Template Variable Edge Cases', () => { const { POST } = await import('./route') const response = await POST(req) + const data = await response.json() expect(response.status).toBe(200) + expect(data.success).toBe(true) + expect(data.output.result).toBe('environment-valueparam-value') }) it('should prioritize environment variables over params for {{}} syntax', async () => { + mockFreestyleExecuteScript.mockResolvedValueOnce({ + result: 'env-wins', + logs: [], + }) + const req = createMockRequest('POST', { code: 'return {{conflictVar}}', envVars: { @@ -524,12 +514,20 @@ describe('Function Execute API - Template Variable Edge Cases', () => { const { POST } = await import('./route') const response = await POST(req) + const data = await response.json() expect(response.status).toBe(200) + expect(data.success).toBe(true) // Environment variable should take precedence + expect(data.output.result).toBe('env-wins') }) it('should handle missing template variables gracefully', async () => { + mockFreestyleExecuteScript.mockResolvedValueOnce({ + result: '', + logs: [], + }) + const req = createMockRequest('POST', { code: 'return {{nonexistent}} + ', envVars: {}, @@ -538,8 +536,10 @@ describe('Function Execute API - Template Variable Edge Cases', () => { const { POST } = await import('./route') const response = await POST(req) + const data = await response.json() expect(response.status).toBe(200) - // Should replace with empty strings for missing variables + expect(data.success).toBe(true) + expect(data.output.result).toBe('') }) }) From 1205d75b869d972b4467ce2ba48a371362e746da Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Sat, 14 Jun 2025 16:43:54 -0700 Subject: [PATCH 4/4] remove extraneous comments --- apps/sim/app/api/function/execute/route.test.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/apps/sim/app/api/function/execute/route.test.ts b/apps/sim/app/api/function/execute/route.test.ts index 9b0b2e8988e..e85fb975965 100644 --- a/apps/sim/app/api/function/execute/route.test.ts +++ b/apps/sim/app/api/function/execute/route.test.ts @@ -428,7 +428,6 @@ describe('Function Execute API Route', () => { const { POST } = await import('./route') const response = await POST(req) - // Should handle complex data gracefully expect(response.status).toBe(200) }) })