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
49 changes: 49 additions & 0 deletions apps/sim/app/api/tools/file/manage/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,11 +8,13 @@ import { ensureAbsoluteUrl } from '@/lib/core/utils/urls'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import {
fetchWorkspaceFileBuffer,
getWorkspaceFile,
getWorkspaceFileByName,
updateWorkspaceFileContent,
uploadWorkspaceFile,
} from '@/lib/uploads/contexts/workspace/workspace-file-manager'
import { getFileExtension, getMimeTypeFromExtension } from '@/lib/uploads/utils/file-utils'
import { assertActiveWorkspaceAccess } from '@/lib/workspaces/permissions/utils'

export const dynamic = 'force-dynamic'

Expand All @@ -39,7 +41,54 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
}

try {
await assertActiveWorkspaceAccess(workspaceId, userId)

switch (body.operation) {
case 'get': {
const { fileId, fileInput } = body
const selectedFileId =
fileId ||
(fileInput && typeof fileInput === 'object' && !Array.isArray(fileInput)
? typeof fileInput.id === 'string'
? fileInput.id
: typeof fileInput.fileId === 'string'
? fileInput.fileId
: ''
: '')

if (!selectedFileId) {
return NextResponse.json({ success: false, error: 'File is required' }, { status: 400 })
}

const file = await getWorkspaceFile(workspaceId, selectedFileId)
if (!file) {
return NextResponse.json(
{ success: false, error: `File not found: "${selectedFileId}"` },
{ status: 404 }
)
}

logger.info('File retrieved', {
fileId: file.id,
name: file.name,
})

return NextResponse.json({
success: true,
data: {
file: {
id: file.id,
name: file.name,
url: ensureAbsoluteUrl(file.path),
size: file.size,
type: file.type,
key: file.key,
context: 'workspace',
},
},
})
}

case 'write': {
const { fileName, content, contentType } = body
const mimeType = contentType || getMimeTypeFromExtension(getFileExtension(fileName))
Expand Down
54 changes: 52 additions & 2 deletions apps/sim/blocks/blocks/file.ts
Original file line number Diff line number Diff line change
Expand Up @@ -266,6 +266,7 @@ export const FileV3Block: BlockConfig<FileParserV3Output> = {
type: 'dropdown' as SubBlockType,
options: [
{ label: 'Read', id: 'file_parser_v3' },
{ label: 'Get', id: 'file_get' },
{ label: 'Write', id: 'file_write' },
{ label: 'Append', id: 'file_append' },
],
Expand Down Expand Up @@ -294,6 +295,28 @@ export const FileV3Block: BlockConfig<FileParserV3Output> = {
required: { field: 'operation', value: 'file_parser_v3' },
condition: { field: 'operation', value: 'file_parser_v3' },
},
{
id: 'getFile',
title: 'File',
type: 'file-upload' as SubBlockType,
canonicalParamId: 'getFileInput',
acceptedTypes: '*',
placeholder: 'Select a workspace file',
multiple: false,
mode: 'basic',
condition: { field: 'operation', value: 'file_get' },
required: { field: 'operation', value: 'file_get' },
},
{
id: 'getFileId',
title: 'File ID',
type: 'short-input' as SubBlockType,
canonicalParamId: 'getFileInput',
placeholder: 'Workspace file ID',
mode: 'advanced',
condition: { field: 'operation', value: 'file_get' },
required: { field: 'operation', value: 'file_get' },
},
{
id: 'fileName',
title: 'File Name',
Expand Down Expand Up @@ -349,7 +372,7 @@ export const FileV3Block: BlockConfig<FileParserV3Output> = {
},
],
tools: {
access: ['file_parser_v3', 'file_write', 'file_append'],
access: ['file_parser_v3', 'file_get', 'file_write', 'file_append'],
config: {
tool: (params) => params.operation || 'file_parser_v3',
params: (params) => {
Expand Down Expand Up @@ -390,6 +413,25 @@ export const FileV3Block: BlockConfig<FileParserV3Output> = {
}
}

if (operation === 'file_get') {
const getInput = params.getFileInput
if (!getInput) {
throw new Error('File is required for get')
}

if (typeof getInput === 'string') {
return {
fileId: getInput.trim(),
workspaceId: params._context?.workspaceId,
}
}

return {
fileInput: normalizeFileInput(getInput, { single: true }),
workspaceId: params._context?.workspaceId,
}
}

const fileInput = params.fileInput
if (!fileInput) {
logger.error('No file input provided')
Expand Down Expand Up @@ -428,9 +470,13 @@ export const FileV3Block: BlockConfig<FileParserV3Output> = {
},
},
inputs: {
operation: { type: 'string', description: 'Operation to perform (read, write, or append)' },
operation: {
type: 'string',
description: 'Operation to perform (read, get, write, or append)',
},
fileInput: { type: 'json', description: 'File input for read' },
fileType: { type: 'string', description: 'File type for read' },
getFileInput: { type: 'json', description: 'Selected file or workspace file ID for get' },
fileName: { type: 'string', description: 'Name for a new file (write)' },
content: { type: 'string', description: 'File content to write' },
contentType: { type: 'string', description: 'MIME content type for write' },
Expand All @@ -446,6 +492,10 @@ export const FileV3Block: BlockConfig<FileParserV3Output> = {
type: 'string',
description: 'All file contents merged into a single text string (read)',
},
file: {
type: 'file',
description: 'Workspace file object (get)',
},
id: {
type: 'string',
description: 'File ID (write)',
Expand Down
14 changes: 13 additions & 1 deletion apps/sim/lib/api/contracts/tools/file.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,9 +22,21 @@ export const fileManageAppendBodySchema = z.object({
content: z.string({ error: 'content is required for append operation' }),
})

export const fileManageBodySchema = z.discriminatedUnion('operation', [
export const fileManageGetBodySchema = z
.object({
operation: z.literal('get'),
workspaceId: z.string().min(1).optional(),
fileId: z.string().min(1).optional(),
fileInput: z.any().optional(),
})
.refine((data) => data.fileId !== undefined || data.fileInput !== undefined, {
message: 'Either fileId or fileInput is required for get operation',
})
Comment on lines +32 to +34
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 The .refine() predicate uses strict !== undefined, so { operation: 'get', fileInput: null } passes Zod validation (since null !== undefined). The route handler catches this downstream with its own empty-ID check, but the schema's intent is to block such payloads early. Using != null (loose inequality) or an explicit truthiness check would keep the two layers consistent.

Suggested change
.refine((data) => data.fileId !== undefined || data.fileInput !== undefined, {
message: 'Either fileId or fileInput is required for get operation',
})
.refine((data) => data.fileId != null || data.fileInput != null, {
message: 'Either fileId or fileInput is required for get operation',
})


export const fileManageBodySchema = z.union([
fileManageWriteBodySchema,
fileManageAppendBodySchema,
fileManageGetBodySchema,
])
Comment on lines +36 to 40
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 discriminatedUnion → union downgrades error quality

The schema was changed from z.discriminatedUnion('operation', [...]) to z.union([...]) because fileManageGetBodySchema uses .refine(), which produces a ZodEffects type incompatible with discriminatedUnion. The trade-off is that with z.union, an invalid operation (e.g. { operation: 'delete' }) causes Zod to try every branch sequentially and return a multi-branch error message rather than a clear discriminant mismatch. Not a runtime bug since invalid operations are still rejected, but consider moving the cross-field validation into the route handler to restore discriminatedUnion and its targeted error messages.


export const fileManageContract = defineRouteContract({
Expand Down
54 changes: 54 additions & 0 deletions apps/sim/tools/file/get.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
import type { ToolConfig, ToolResponse, WorkflowToolExecutionContext } from '@/tools/types'

interface FileGetParams {
fileId?: string
fileInput?: unknown
workspaceId?: string
_context?: WorkflowToolExecutionContext
}

export const fileGetTool: ToolConfig<FileGetParams, ToolResponse> = {
id: 'file_get',
name: 'File Get',
description: 'Get a workspace file object from a selected file or canonical workspace file ID.',
version: '1.0.0',

params: {
fileId: {
type: 'string',
required: false,
visibility: 'user-or-llm',
description: 'Canonical workspace file ID.',
},
fileInput: {
type: 'file',
required: false,
visibility: 'user-only',
description: 'Selected workspace file object.',
},
},

request: {
url: '/api/tools/file/manage',
method: 'POST',
headers: () => ({ 'Content-Type': 'application/json' }),
body: (params) => ({
operation: 'get',
fileId: params.fileId,
fileInput: params.fileInput,
workspaceId: params.workspaceId || params._context?.workspaceId,
}),
},

transformResponse: async (response) => {
const data = await response.json()
if (!response.ok || !data.success) {
return { success: false, output: {}, error: data.error || 'Failed to get file' }
}
return { success: true, output: data.data }
},

outputs: {
file: { type: 'file', description: 'Workspace file object' },
},
}
1 change: 1 addition & 0 deletions apps/sim/tools/file/index.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { fileParserTool, fileParserV2Tool, fileParserV3Tool } from '@/tools/file/parser'

export { fileAppendTool } from '@/tools/file/append'
export { fileGetTool } from '@/tools/file/get'
export { fileWriteTool } from '@/tools/file/write'

export const fileParseTool = fileParserTool
Expand Down
2 changes: 2 additions & 0 deletions apps/sim/tools/registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -643,6 +643,7 @@ import {
} from '@/tools/fathom'
import {
fileAppendTool,
fileGetTool,
fileParserV2Tool,
fileParserV3Tool,
fileParseTool,
Expand Down Expand Up @@ -3213,6 +3214,7 @@ export const tools: Record<string, ToolConfig> = {
file_parser_v2: fileParserV2Tool,
file_parser_v3: fileParserV3Tool,
file_append: fileAppendTool,
file_get: fileGetTool,
file_write: fileWriteTool,
firecrawl_scrape: firecrawlScrapeTool,
firecrawl_search: firecrawlSearchTool,
Expand Down
Loading