Skip to content
Open
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
7 changes: 4 additions & 3 deletions apps/docs/content/docs/en/integrations/file.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ In Sim, the File block allows your agents to read and extract text from workspac

## Usage Instructions

Read workspace file objects, extract the text content of files, fetch and parse files from URLs with optional headers, write new workspace files, append content to existing files, compress files into a .zip archive, extract a .zip archive into the workspace, or manage the public share link for a file.
Read workspace file objects, extract the text content of files, fetch and parse files from URLs with optional headers, write new workspace files at relative paths, append content to existing files, compress files into a .zip archive, extract a .zip archive into the workspace, or manage the public share link for a file.



Expand Down Expand Up @@ -86,13 +86,13 @@ Fetch and parse a file from a URL with optional custom headers.

### File Write

Create a new workspace file. If a file with the same name already exists, a numeric suffix is added (e.g., "data (1).csv").
Create a new workspace file at a relative path. Missing folders are created automatically. If a file with the same name already exists, a numeric suffix is added (e.g., "data (1).csv").

#### Input

| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `fileName` | string | Yes | File name \(e.g., "data.csv"\). If a file with this name exists, a numeric suffix is added automatically. |
| `fileName` | string | Yes | Relative workspace file path \(e.g., "Reports/2026/report.md"\). Missing folders are created automatically, and name conflicts receive a numeric suffix. |
| `content` | string | Yes | The text content to write to the file. |
| `contentType` | string | No | MIME type for new files \(e.g., "text/plain"\). Auto-detected from file extension if omitted. |

Expand All @@ -102,6 +102,7 @@ Create a new workspace file. If a file with the same name already exists, a nume
| --------- | ---- | ----------- |
| `id` | string | File ID |
| `name` | string | File name |
| `vfsPath` | string | Canonical workspace path of the created file \(e.g., files/Reports/2026/report.md\) |
| `size` | number | File size in bytes |
| `url` | string | URL to access the file |

Expand Down
65 changes: 65 additions & 0 deletions apps/sim/app/api/tools/file/manage/route.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -368,6 +368,13 @@ describe('POST /api/tools/file/manage content provenance', () => {
})

it('preserves existing file-path behavior when a filename was resolved from a secret', async () => {
mockUploadWorkspaceFile.mockResolvedValue({
...workspaceFile('new-file'),
name: 'secret-value.txt',
folderId: 'folder-1',
folderPath: 'Reports & Plans/2026',
url: '/api/files/serve/new-file',
})
const response = await POST(
createMockRequest(
'POST',
Expand Down Expand Up @@ -397,6 +404,14 @@ describe('POST /api/tools/file/manage content provenance', () => {
)

expect(response.status).toBe(200)
await expect(response.clone().json()).resolves.toMatchObject({
success: true,
data: {
id: 'new-file',
name: 'secret-value.txt',
vfsPath: 'files/Reports%20%26%20Plans/2026/secret-value.txt',
},
})
expect(mockEnsureWorkspaceFileFolderPath).toHaveBeenCalledWith(
expect.objectContaining({
principal: expect.objectContaining({ kind: 'delegated', subjectUserId: 'user-1' }),
Expand Down Expand Up @@ -441,8 +456,58 @@ describe('POST /api/tools/file/manage content provenance', () => {
secretProvenance: { status: 'exact', entries: [] },
}
)
await expect(response.clone().json()).resolves.toMatchObject({
success: true,
data: { id: 'new-file', name: 'new.txt', vfsPath: 'files/new.txt' },
})
})

it('returns the actual conflict-resolved nested path', async () => {
mockUploadWorkspaceFile.mockResolvedValue({
...workspaceFile('new-file'),
name: 'report (1).md',
folderId: 'folder-1',
folderPath: 'Reports/2026',
url: '/api/files/serve/new-file',
})

const response = await POST(
createMockRequest('POST', {
operation: 'write',
workspaceId: 'workspace-1',
fileName: 'Reports/2026/report.md',
content: 'report',
})
)

expect(response.status).toBe(200)
await expect(response.json()).resolves.toMatchObject({
success: true,
data: {
name: 'report (1).md',
vfsPath: 'files/Reports/2026/report%20(1).md',
},
})
})

it.each(['Reports/../report.md', 'Reports\\2026\\report.md'])(
'rejects invalid write path %j before creating folders',
async (fileName) => {
const response = await POST(
createMockRequest('POST', {
operation: 'write',
workspaceId: 'workspace-1',
fileName,
content: 'report',
})
)

expect(response.status).toBe(400)
expect(mockEnsureWorkspaceFileFolderPath).not.toHaveBeenCalled()
expect(mockUploadWorkspaceFile).not.toHaveBeenCalled()
}
)

it.each([
['Reports & Plans/2026', '/Reports%20%26%20Plans/2026'],
['', '/'],
Expand Down
9 changes: 7 additions & 2 deletions apps/sim/app/api/tools/file/manage/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,6 @@ import { type NextRequest, NextResponse } from 'next/server'
import { fileManageContract } from '@/lib/api/contracts/tools/file'
import { parseRequest } from '@/lib/api/server'
import { AuthType, type AuthTypeValue, checkInternalAuth } from '@/lib/auth/hybrid'
import { splitWorkspaceFilePath } from '@/lib/copilot/tools/server/files/workspace-file'
import { acquireLock, releaseLock } from '@/lib/core/config/redis'
import { OrchestrationError } from '@/lib/core/orchestration/types'
import { generateRequestId } from '@/lib/core/utils/request'
Expand Down Expand Up @@ -67,6 +66,10 @@ import { updateWorkspaceFileShare } from '@/lib/workspace-files/application/shar
import { updateWorkspaceFileContent } from '@/lib/workspace-files/application/update-workspace-file-content'
import { ensureWorkspaceFileFolderPathOperation } from '@/lib/workspace-files/application/workspace-file-folders'
import { MAX_WORKSPACE_FILE_CONTENT_BYTES } from '@/lib/workspace-files/orchestration'
import {
parseRelativeWorkspaceFileCreatePath,
workspaceFileVfsPath,
} from '@/lib/workspace-files/workspace-file-path'
import { isWorkspaceAccessDeniedError } from '@/lib/workspaces/permissions/utils'
import { assertToolFileAccess } from '@/app/api/files/authorization'
import type { UserFile } from '@/executor/types'
Expand Down Expand Up @@ -716,7 +719,8 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
{ status: 400 }
)
}
const { folderSegments, leafName } = splitWorkspaceFilePath(fileName)
const { folderSegments, fileName: leafName } =
parseRelativeWorkspaceFileCreatePath(fileName)
await admitCreateWorkspaceFile(principal, workspaceId)
const { folderId } = await ensureWorkspaceFileFolderPathOperation.execute({
principal,
Expand Down Expand Up @@ -755,6 +759,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
name: result.file.name,
size: fileBuffer.length,
url: ensureAbsoluteUrl(result.file.url ?? result.file.path),
vfsPath: workspaceFileVfsPath(result.file),
},
})
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,11 @@ import { isApiClientError } from '@/lib/api/client/errors'
import { requestJson } from '@/lib/api/client/request'
import { fileDeleteContract } from '@/lib/api/contracts/storage-transfer'
import { getExtensionFromMimeType } from '@/lib/uploads/utils/file-utils'
import {
findSelectedWorkspaceFile,
getWorkspaceFileDisplayLabel,
workspaceFileMatchesSelection,
} from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/file-upload/workspace-file-display'
import { formatDisplayText } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/formatted-text'
import { getWorkflowSearchLabelHighlight } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/workflow-search-highlight'
import { useSubBlockValue } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-sub-block-value'
Expand Down Expand Up @@ -56,15 +61,18 @@ interface FileUploadProps {
}

export interface UploadedFile {
id?: string
name: string
path: string
key?: string
folderPath?: string | null
size: number
type: string
}

interface SingleFileSelectorProps {
file: UploadedFile
displayName: string
options: Array<{ label: string; value: string; disabled?: boolean }>
selectedValue: string
inputValue: string
Expand All @@ -86,6 +94,7 @@ interface SingleFileSelectorProps {
*/
function SingleFileSelector({
file,
displayName,
options,
selectedValue,
inputValue,
Expand All @@ -99,7 +108,7 @@ function SingleFileSelector({
isDeleting,
workflowSearchHighlight,
}: SingleFileSelectorProps) {
const displayLabel = `${truncateMiddle(file.name, 20, 12)} (${formatFileSize(file.size)})`
const displayLabel = `${truncateMiddle(displayName, 20, 12)} (${formatFileSize(file.size)})`
const [searchQuery, setSearchQuery] = useState('')
const [isEditing, setIsEditing] = useState(false)
// When not editing, always show the file's display label. When editing, show the user's query.
Expand Down Expand Up @@ -277,11 +286,8 @@ export function FileUpload({
const availableWorkspaceFiles = workspaceFiles.filter((workspaceFile) => {
const existingFiles = Array.isArray(value) ? value : value ? [value] : []

const isAlreadySelected = existingFiles.some(
(existing) =>
existing.name === workspaceFile.name ||
existing.path?.includes(workspaceFile.key) ||
existing.key === workspaceFile.key
const isAlreadySelected = existingFiles.some((existing) =>
workspaceFileMatchesSelection(workspaceFile, existing)
)

return !isAlreadySelected
Expand Down Expand Up @@ -389,9 +395,11 @@ export function FileUpload({
})

uploadedFiles.push({
id: data.file.id,
name: data.file.name,
path: data.file.url,
key: data.file.key,
folderPath: null,
size: data.file.size,
type: data.file.type,
})
Expand Down Expand Up @@ -481,9 +489,11 @@ export function FileUpload({
if (!selectedFile) return

const uploadedFile: UploadedFile = {
id: selectedFile.id,
name: selectedFile.name,
path: selectedFile.path,
key: selectedFile.key,
folderPath: selectedFile.folderPath,
size: selectedFile.size,
type: selectedFile.type,
}
Expand Down Expand Up @@ -558,7 +568,9 @@ export function FileUpload({
const renderFileItem = (file: UploadedFile, index: number) => {
const fileKey = file.path || ''
const isDeleting = deletingFiles[fileKey]
const displayName = truncateMiddle(file.name)
const matchedWorkspaceFile = findSelectedWorkspaceFile(workspaceFiles, file)
const fullDisplayName = getWorkspaceFileDisplayLabel(matchedWorkspaceFile ?? file)
const displayName = truncateMiddle(fullDisplayName)
const workflowSearchHighlight = getWorkflowSearchLabelHighlight({
activeSearchTarget,
blockId,
Expand All @@ -572,7 +584,7 @@ export function FileUpload({
key={fileKey}
className='relative rounded-sm border border-[var(--border-1)] bg-[var(--surface-5)] px-2 py-1.5 hover-hover:bg-[var(--surface-active)] dark:bg-[var(--surface-5)]'
>
<div className='truncate pr-6 text-sm' title={file.name}>
<div className='truncate pr-6 text-sm' title={fullDisplayName}>
<span className='text-[var(--text-primary)]'>
{formatDisplayText(displayName, { workflowSearchHighlight })}
</span>
Expand Down Expand Up @@ -624,7 +636,7 @@ export function FileUpload({
const isAccepted =
!acceptedTypes || acceptedTypes === '*' || isFileTypeAccepted(file.type, acceptedTypes)
return {
label: file.name,
label: getWorkspaceFileDisplayLabel(file),
value: file.id,
// When cloud is required, local workspace files are also unpublishable.
disabled: !isAccepted || cloudUploadBlocked,
Expand All @@ -642,7 +654,7 @@ export function FileUpload({
const isAccepted =
!acceptedTypes || acceptedTypes === '*' || isFileTypeAccepted(file.type, acceptedTypes)
return {
label: file.name,
label: getWorkspaceFileDisplayLabel(file),
value: file.id,
disabled: !isAccepted || cloudUploadBlocked,
}
Expand All @@ -656,13 +668,7 @@ export function FileUpload({
if (!hasFiles || multiple) return ''
const currentFile = filesArray[0]
if (!currentFile) return ''
// Match by key or path
const matchedWorkspaceFile = workspaceFiles.find(
(wf) =>
wf.key === currentFile.key ||
wf.name === currentFile.name ||
currentFile.path?.includes(wf.key)
)
const matchedWorkspaceFile = findSelectedWorkspaceFile(workspaceFiles, currentFile)
return matchedWorkspaceFile?.id || ''
}, [filesArray, workspaceFiles, hasFiles, multiple])

Expand Down Expand Up @@ -768,6 +774,9 @@ export function FileUpload({
{hasFiles && !multiple && !isUploading && (
<SingleFileSelector
file={filesArray[0]}
displayName={getWorkspaceFileDisplayLabel(
findSelectedWorkspaceFile(workspaceFiles, filesArray[0]) ?? filesArray[0]
)}
options={singleFileOptions}
selectedValue={selectedFileId}
inputValue={inputValue}
Expand All @@ -786,7 +795,13 @@ export function FileUpload({
blockId,
subBlockId,
valuePath: [],
label: `${truncateMiddle(filesArray[0].name, 20, 12)} (${formatFileSize(filesArray[0].size)})`,
label: `${truncateMiddle(
getWorkspaceFileDisplayLabel(
findSelectedWorkspaceFile(workspaceFiles, filesArray[0]) ?? filesArray[0]
),
20,
12
)} (${formatFileSize(filesArray[0].size)})`,
})}
/>
)}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
/**
* @vitest-environment node
*/
import { describe, expect, it } from 'vitest'
import {
findSelectedWorkspaceFile,
getWorkspaceFileDisplayLabel,
workspaceFileMatchesSelection,
} from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/file-upload/workspace-file-display'

const reportsFile = {
id: 'file-reports',
name: 'report.md',
key: 'workspace/workspace-1/report-reports.md',
path: '/api/files/serve/report-reports',
folderPath: 'Reports/2026',
}

const archiveFile = {
id: 'file-archive',
name: 'report.md',
key: 'workspace/workspace-1/report-archive.md',
path: '/api/files/serve/report-archive',
folderPath: 'Archive',
}

describe('workspace file picker display', () => {
it('shows folder breadcrumbs while keeping root-level labels compact', () => {
expect(getWorkspaceFileDisplayLabel(reportsFile)).toBe('Reports / 2026 / report.md')
expect(getWorkspaceFileDisplayLabel({ name: 'root.md', folderPath: null })).toBe('root.md')
})

it('decodes escaped slashes in folder display paths', () => {
expect(
getWorkspaceFileDisplayLabel({
name: 'contract.pdf',
folderPath: 'Finance\\/Legal/2026',
})
).toBe('Finance/Legal / 2026 / contract.pdf')
})

it('uses the persisted ID to disambiguate duplicate leaf names', () => {
expect(
findSelectedWorkspaceFile([reportsFile, archiveFile], {
id: archiveFile.id,
name: 'report.md',
})
).toBe(archiveFile)
expect(
workspaceFileMatchesSelection(reportsFile, {
id: archiveFile.id,
name: 'report.md',
})
).toBe(false)
})

it('retains name matching for legacy saved values without stable identifiers', () => {
expect(
findSelectedWorkspaceFile([reportsFile, archiveFile], {
name: 'report.md',
})
).toBe(reportsFile)
})

it('matches id-less values with folder metadata by their complete location', () => {
expect(
findSelectedWorkspaceFile([reportsFile, archiveFile], {
name: 'report.md',
folderPath: 'Archive',
})
).toBe(archiveFile)
})
})
Loading
Loading