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
5 changes: 3 additions & 2 deletions apps/docs/content/docs/integrations/file.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -117,16 +117,17 @@ Fetch and parse a file from a URL with optional custom headers.

### File Write

Create a new workspace file, either from text content or from an existing 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, either from text content or from an existing file. If a file with the same name already exists, a numeric suffix is added (e.g., "data (1).csv") unless overwrite is enabled.

#### Input

| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `fileName` | string | No | File name \(e.g., "data.csv"\). Required when writing text; optional when storing a file, which keeps its own name unless this overrides it. If the name already exists, a numeric suffix is added automatically. |
| `fileName` | string | No | File name \(e.g., "data.csv"\). Required when writing text; optional when storing a file, which keeps its own name unless this overrides it. If the name already exists, a numeric suffix is added automatically unless overwrite is enabled. |
| `content` | string | No | The text content to write to the file. Provide exactly one of content or fileInput. |
| `fileInput` | file | No | An existing file to store in the workspace, such as one produced by an earlier tool. Use this for anything that is not text — PDFs, images, audio, archives. Provide exactly one of content or fileInput. |
| `contentType` | string | No | MIME type for new files \(e.g., "text/plain"\). Auto-detected from the file extension, or taken from the stored file, if omitted. |
| `overwrite` | boolean | No | Replace the contents of an existing file at the exact target path \(folder and name\) instead of creating a suffixed copy. Creates the file when that path does not exist yet. |

#### Output

Expand Down
13 changes: 12 additions & 1 deletion apps/sim/blocks/blocks/file.ts
Original file line number Diff line number Diff line change
Expand Up @@ -911,7 +911,7 @@ export const FileV5Block: BlockConfig<FileParserV3Output> = {
- Search finds literal text across all active workspace files and returns structured results with fileId, lineNumber, and text. Lowercase queries are case-insensitive; adding any uppercase letter makes the search case-sensitive.
- Search is eventually consistent. Check "complete" and "indexStatus" when pending, failed, skipped, or partially indexed files matter to the task.
- Use Fetch for external file URLs. Add headers for authenticated downloads, for example Slack private file URLs require an Authorization Bearer token.
- Use Write to create a new workspace file and Append to add content to an existing one.
- Use Write to create a new workspace file and Append to add content to an existing one. Write adds a numeric suffix when the name is taken; turn on "Overwrite Existing File" to replace the contents of the file at that exact path (folder and name) instead — a same-named file in another folder is left alone.
- Use Compress to bundle one or more files into a single .zip archive stored in the workspace. The new archive is returned in the "files" output.
- Use Decompress to extract a .zip archive back into the workspace; the extracted files are returned in the "files" output, ready to chain into Get Content or downstream blocks.
`,
Expand Down Expand Up @@ -1088,6 +1088,12 @@ export const FileV5Block: BlockConfig<FileParserV3Output> = {
condition: { field: 'operation', value: 'file_write' },
mode: 'advanced',
},
{
id: 'overwrite',
title: 'Overwrite Existing File',
type: 'switch' as SubBlockType,
condition: { field: 'operation', value: 'file_write' },
},
{
id: 'appendFile',
title: 'File',
Expand Down Expand Up @@ -1284,6 +1290,7 @@ export const FileV5Block: BlockConfig<FileParserV3Output> = {
...(omitContent ? {} : { content: params.content }),
...(fileInput ? { fileInput } : {}),
contentType: params.contentType,
overwrite: params.overwrite === true || params.overwrite === 'true',
workspaceId: params._context?.workspaceId,
}
}
Expand Down Expand Up @@ -1513,6 +1520,10 @@ export const FileV5Block: BlockConfig<FileParserV3Output> = {
description: 'An existing file to store in the workspace, instead of text content',
},
contentType: { type: 'string', description: 'MIME content type for write' },
overwrite: {
type: 'boolean',
description: 'Replace an existing file with the same name instead of creating a copy (write)',
},
appendFileInput: { type: 'json', description: 'File to append to' },
appendContent: { type: 'string', description: 'Content to append to file' },
compressInput: {
Expand Down
1 change: 1 addition & 0 deletions apps/sim/lib/api/contracts/tools/file.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ export const fileManageWriteBodySchema = z
*/
fileInput: z.unknown().optional(),
contentType: z.string().optional(),
overwrite: z.boolean().optional(),
[PRIVATE_SECRET_PROVENANCE_FIELD]: privateSecretProvenanceBundleSchema.optional(),
})
.superRefine((body, context) => {
Expand Down
205 changes: 205 additions & 0 deletions apps/sim/lib/internal/file/operations.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -164,6 +164,7 @@ vi.mock('@/app/api/files/authorization', () => ({

import { fileManageBodySchema } from '@/lib/api/contracts/tools/file'
import { executeFileManageOperation } from '@/lib/internal/file/operations'
import { FileConflictError } from '@/lib/uploads/contexts/workspace'
import { createWorkspaceFileDelegatedPrincipal } from '@/lib/workspace-files/application/delegated-principal'

async function POST(request: Request): Promise<Response> {
Expand Down Expand Up @@ -621,6 +622,210 @@ describe('file manage operations', () => {
)
})

it('replaces the existing file at the target path when overwrite is on', async () => {
const existing = workspaceFile('report')
mockResolveWorkspaceFileReference.mockResolvedValue(existing)
mockUpdateWorkspaceFileContent.mockResolvedValue(existing)

const response = await POST(
createMockRequest('POST', {
operation: 'write',
workspaceId: 'workspace-1',
fileName: 'report.txt',
content: 'fresh',
overwrite: true,
})
)

expect(response.status).toBe(200)
expect(mockUploadWorkspaceFile).not.toHaveBeenCalled()
expect(mockUpdateWorkspaceFileContent).toHaveBeenCalledWith(
'workspace-1',
'report',
'user-1',
Buffer.from('fresh'),
'text/plain',
{
expectedUpdatedAt: CONTENT_UPDATED_AT,
secretProvenancePolicy: { mode: 'replace', provenance: { status: 'exact', entries: [] } },
}
)
await expect(response.json()).resolves.toMatchObject({
success: true,
data: { id: 'report', name: 'report.txt' },
})
})

it('creates the file when overwrite finds nothing at the target path', async () => {
mockResolveWorkspaceFileReference.mockResolvedValue(null)
Comment thread
TheodoreSpeaks marked this conversation as resolved.
Comment thread
TheodoreSpeaks marked this conversation as resolved.

const response = await POST(
createMockRequest('POST', {
operation: 'write',
workspaceId: 'workspace-1',
fileName: 'report.txt',
content: 'fresh',
overwrite: true,
})
)

expect(response.status).toBe(200)
expect(mockUpdateWorkspaceFileContent).not.toHaveBeenCalled()
expect(mockUploadWorkspaceFile).toHaveBeenCalledWith(
'workspace-1',
'user-1',
Buffer.from('fresh'),
'report.txt',
'text/plain',
// Exact, so a path created by a concurrent write conflicts instead of being suffixed.
expect.objectContaining({ exactName: true, folderId: null })
)
})

it('surfaces a conflict when a concurrent write claims the overwrite path', async () => {
mockResolveWorkspaceFileReference.mockResolvedValue(null)
mockUploadWorkspaceFile.mockRejectedValue(new FileConflictError('report.txt'))

const response = await POST(
createMockRequest('POST', {
operation: 'write',
workspaceId: 'workspace-1',
fileName: 'report.txt',
content: 'fresh',
overwrite: true,
})
)

expect(response.status).toBe(409)
await expect(response.json()).resolves.toMatchObject({ success: false })
})

it('never overwrites a same-named file resolved outside the target folder', async () => {
mockResolveWorkspaceFileReference.mockResolvedValue({
...workspaceFile('report'),
folderId: 'folder-9',
})

const response = await POST(
createMockRequest('POST', {
operation: 'write',
workspaceId: 'workspace-1',
fileName: 'report.txt',
content: 'fresh',
overwrite: true,
})
)

expect(response.status).toBe(200)
expect(mockUpdateWorkspaceFileContent).not.toHaveBeenCalled()
expect(mockUploadWorkspaceFile).toHaveBeenCalled()
})

it('keeps the suffixing create path when overwrite is off', async () => {
mockResolveWorkspaceFileReference.mockResolvedValue(workspaceFile('report'))

const response = await POST(
createMockRequest('POST', {
operation: 'write',
workspaceId: 'workspace-1',
fileName: 'report.txt',
content: 'fresh',
})
)

expect(response.status).toBe(200)
expect(mockUpdateWorkspaceFileContent).not.toHaveBeenCalled()
expect(mockUploadWorkspaceFile).toHaveBeenCalledWith(
'workspace-1',
'user-1',
Buffer.from('fresh'),
'report.txt',
'text/plain',
expect.objectContaining({ exactName: false })
)
})

it('overwrites an existing file with the bytes of a stored file input', async () => {
const existing = workspaceFile('report')
mockResolveWorkspaceFileReference.mockResolvedValue(existing)
mockUpdateWorkspaceFileContent.mockResolvedValue(existing)
mockGetBoundWorkspaceFileSecretProvenance.mockResolvedValue({ status: 'exact', entries: [] })

const response = await POST(
createMockRequest('POST', {
operation: 'write',
workspaceId: 'workspace-1',
fileName: 'report.txt',
fileInput: {
key: 'workspace/workspace-1/source.txt',
name: 'source.txt',
type: 'text/plain',
size: 6,
},
overwrite: true,
})
)

expect(response.status).toBe(200)
expect(mockUploadWorkspaceFile).not.toHaveBeenCalled()
expect(mockUpdateWorkspaceFileContent).toHaveBeenCalledWith(
'workspace-1',
'report',
'user-1',
Buffer.from('content:source.txt'),
'text/plain',
expect.objectContaining({ expectedUpdatedAt: CONTENT_UPDATED_AT })
)
})

it('downgrades provenance when overwriting a file owned by another user', async () => {
const existing = workspaceFile('report', 'other-user')
mockResolveWorkspaceFileReference.mockResolvedValue(existing)
mockUpdateWorkspaceFileContent.mockResolvedValue(existing)

const response = await POST(
createMockRequest(
'POST',
{
operation: 'write',
workspaceId: 'workspace-1',
fileName: 'report.txt',
content: 'secret-value',
overwrite: true,
__privateSecretProvenance: {
version: 1,
complete: true,
selections: [
{
key: 'content',
provenance: {
version: 1,
complete: true,
entries: [{ name: 'TOKEN', encryptedValue: 'encrypted-token' }],
scope: { userId: 'user-1', workspaceId: 'workspace-1' },
},
},
],
},
},
PRIVATE_SECRET_PROVENANCE_HEADER
)
)

expect(response.status).toBe(200)
expect(mockUpdateWorkspaceFileContent).toHaveBeenCalledWith(
'workspace-1',
'report',
'user-1',
Buffer.from('secret-value'),
'text/plain',
{
expectedUpdatedAt: CONTENT_UPDATED_AT,
secretProvenancePolicy: { mode: 'replace', provenance: { status: 'unknown' } },
}
)
})

it('atomically binds append provenance to the exact predecessor version', async () => {
const existing = workspaceFile('file-1')
mockResolveWorkspaceFileReference.mockResolvedValue(existing)
Expand Down
Loading
Loading