feat(tools): added sftp tool to accompany smtp and ssh tools (#2261)

This commit is contained in:
Waleed
2025-12-08 19:21:10 -08:00
committed by GitHub
parent 5af67d08ba
commit dafd2f5ce8
23 changed files with 2421 additions and 2 deletions

View File

@@ -3798,6 +3798,23 @@ export function SshIcon(props: SVGProps<SVGSVGElement>) {
)
}
export function SftpIcon(props: SVGProps<SVGSVGElement>) {
return (
<svg
{...props}
xmlns='http://www.w3.org/2000/svg'
viewBox='0 0 32 32'
width='32px'
height='32px'
>
<path
d='M 6 3 L 6 29 L 26 29 L 26 9.59375 L 25.71875 9.28125 L 19.71875 3.28125 L 19.40625 3 Z M 8 5 L 18 5 L 18 11 L 24 11 L 24 27 L 8 27 Z M 20 6.4375 L 22.5625 9 L 20 9 Z'
fill='currentColor'
/>
</svg>
)
}
export function ApifyIcon(props: SVGProps<SVGSVGElement>) {
return (
<svg

View File

@@ -86,6 +86,7 @@ import {
SendgridIcon,
SentryIcon,
SerperIcon,
SftpIcon,
ShopifyIcon,
SlackIcon,
SmtpIcon,
@@ -148,6 +149,7 @@ export const blockTypeToIconMap: Record<string, IconComponent> = {
slack: SlackIcon,
shopify: ShopifyIcon,
sharepoint: MicrosoftSharepointIcon,
sftp: SftpIcon,
serper: SerperIcon,
sentry: SentryIcon,
sendgrid: SendgridIcon,

View File

@@ -81,6 +81,7 @@
"sendgrid",
"sentry",
"serper",
"sftp",
"sharepoint",
"shopify",
"slack",

View File

@@ -0,0 +1,188 @@
---
title: SFTP
description: Transfer files via SFTP (SSH File Transfer Protocol)
---
import { BlockInfoCard } from "@/components/ui/block-info-card"
<BlockInfoCard
type="sftp"
color="#2D3748"
/>
{/* MANUAL-CONTENT-START:intro */}
[SFTP (SSH File Transfer Protocol)](https://en.wikipedia.org/wiki/SSH_File_Transfer_Protocol) is a secure network protocol that enables you to upload, download, and manage files on remote servers. SFTP operates over SSH, making it ideal for automated, encrypted file transfers and remote file management within modern workflows.
With SFTP tools integrated into Sim, you can easily automate the movement of files between your AI agents and external systems or servers. This empowers your agents to manage critical data exchanges, backups, document generation, and remote system orchestration—all with robust security.
**Key functionality available via SFTP tools:**
- **Upload Files:** Seamlessly transfer files of any type from your workflow to a remote server, with support for both password and SSH private key authentication.
- **Download Files:** Retrieve files from remote SFTP servers directly for processing, archiving, or further automation.
- **List & Manage Files:** Enumerate directories, delete or create files and folders, and manage file system permissions remotely.
- **Flexible Authentication:** Connect using either traditional passwords or SSH keys, with support for passphrases and permissions control.
- **Large File Support:** Programmatically manage large file uploads and downloads, with built-in size limits for safety.
By integrating SFTP into Sim, you can automate secure file operations as part of any workflow, whether its data collection, reporting, remote system maintenance, or dynamic content exchange between platforms.
The sections below describe the key SFTP tools available:
- **sftp_upload:** Upload one or more files to a remote server.
- **sftp_download:** Download files from a remote server to your workflow.
- **sftp_list:** List directory contents on a remote SFTP server.
- **sftp_delete:** Delete files or directories from a remote server.
- **sftp_create:** Create new files on a remote SFTP server.
- **sftp_mkdir:** Create new directories remotely.
See the tool documentation below for detailed input and output parameters for each operation.
{/* MANUAL-CONTENT-END */}
## Usage Instructions
Upload, download, list, and manage files on remote servers via SFTP. Supports both password and private key authentication for secure file transfers.
## Tools
### `sftp_upload`
Upload files to a remote SFTP server
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `host` | string | Yes | SFTP server hostname or IP address |
| `port` | number | Yes | SFTP server port \(default: 22\) |
| `username` | string | Yes | SFTP username |
| `password` | string | No | Password for authentication \(if not using private key\) |
| `privateKey` | string | No | Private key for authentication \(OpenSSH format\) |
| `passphrase` | string | No | Passphrase for encrypted private key |
| `remotePath` | string | Yes | Destination directory on the remote server |
| `files` | file[] | No | Files to upload |
| `fileContent` | string | No | Direct file content to upload \(for text files\) |
| `fileName` | string | No | File name when using direct content |
| `overwrite` | boolean | No | Whether to overwrite existing files \(default: true\) |
| `permissions` | string | No | File permissions \(e.g., 0644\) |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `success` | boolean | Whether the upload was successful |
| `uploadedFiles` | json | Array of uploaded file details \(name, remotePath, size\) |
| `message` | string | Operation status message |
### `sftp_download`
Download a file from a remote SFTP server
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `host` | string | Yes | SFTP server hostname or IP address |
| `port` | number | Yes | SFTP server port \(default: 22\) |
| `username` | string | Yes | SFTP username |
| `password` | string | No | Password for authentication \(if not using private key\) |
| `privateKey` | string | No | Private key for authentication \(OpenSSH format\) |
| `passphrase` | string | No | Passphrase for encrypted private key |
| `remotePath` | string | Yes | Path to the file on the remote server |
| `encoding` | string | No | Output encoding: utf-8 for text, base64 for binary \(default: utf-8\) |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `success` | boolean | Whether the download was successful |
| `fileName` | string | Name of the downloaded file |
| `content` | string | File content \(text or base64 encoded\) |
| `size` | number | File size in bytes |
| `encoding` | string | Content encoding \(utf-8 or base64\) |
| `message` | string | Operation status message |
### `sftp_list`
List files and directories on a remote SFTP server
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `host` | string | Yes | SFTP server hostname or IP address |
| `port` | number | Yes | SFTP server port \(default: 22\) |
| `username` | string | Yes | SFTP username |
| `password` | string | No | Password for authentication \(if not using private key\) |
| `privateKey` | string | No | Private key for authentication \(OpenSSH format\) |
| `passphrase` | string | No | Passphrase for encrypted private key |
| `remotePath` | string | Yes | Directory path on the remote server |
| `detailed` | boolean | No | Include detailed file information \(size, permissions, modified date\) |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `success` | boolean | Whether the operation was successful |
| `path` | string | Directory path that was listed |
| `entries` | json | Array of directory entries with name, type, size, permissions, modifiedAt |
| `count` | number | Number of entries in the directory |
| `message` | string | Operation status message |
### `sftp_delete`
Delete a file or directory on a remote SFTP server
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `host` | string | Yes | SFTP server hostname or IP address |
| `port` | number | Yes | SFTP server port \(default: 22\) |
| `username` | string | Yes | SFTP username |
| `password` | string | No | Password for authentication \(if not using private key\) |
| `privateKey` | string | No | Private key for authentication \(OpenSSH format\) |
| `passphrase` | string | No | Passphrase for encrypted private key |
| `remotePath` | string | Yes | Path to the file or directory to delete |
| `recursive` | boolean | No | Delete directories recursively |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `success` | boolean | Whether the deletion was successful |
| `deletedPath` | string | Path that was deleted |
| `message` | string | Operation status message |
### `sftp_mkdir`
Create a directory on a remote SFTP server
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `host` | string | Yes | SFTP server hostname or IP address |
| `port` | number | Yes | SFTP server port \(default: 22\) |
| `username` | string | Yes | SFTP username |
| `password` | string | No | Password for authentication \(if not using private key\) |
| `privateKey` | string | No | Private key for authentication \(OpenSSH format\) |
| `passphrase` | string | No | Passphrase for encrypted private key |
| `remotePath` | string | Yes | Path for the new directory |
| `recursive` | boolean | No | Create parent directories if they do not exist |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `success` | boolean | Whether the directory was created successfully |
| `createdPath` | string | Path of the created directory |
| `message` | string | Operation status message |
## Notes
- Category: `tools`
- Type: `sftp`

View File

@@ -7,7 +7,7 @@ import { BlockInfoCard } from "@/components/ui/block-info-card"
<BlockInfoCard
type="smtp"
color="#4A5568"
color="#2D3748"
/>
{/* MANUAL-CONTENT-START:intro */}

View File

@@ -0,0 +1,188 @@
import { type NextRequest, NextResponse } from 'next/server'
import type { SFTPWrapper } from 'ssh2'
import { z } from 'zod'
import { checkHybridAuth } from '@/lib/auth/hybrid'
import { generateRequestId } from '@/lib/core/utils/request'
import { createLogger } from '@/lib/logs/console/logger'
import {
createSftpConnection,
getFileType,
getSftp,
isPathSafe,
sanitizePath,
sftpIsDirectory,
} from '@/app/api/tools/sftp/utils'
export const dynamic = 'force-dynamic'
const logger = createLogger('SftpDeleteAPI')
const DeleteSchema = z.object({
host: z.string().min(1, 'Host is required'),
port: z.coerce.number().int().positive().default(22),
username: z.string().min(1, 'Username is required'),
password: z.string().nullish(),
privateKey: z.string().nullish(),
passphrase: z.string().nullish(),
remotePath: z.string().min(1, 'Remote path is required'),
recursive: z.boolean().default(false),
})
/**
* Recursively deletes a directory and all its contents
*/
async function deleteRecursive(sftp: SFTPWrapper, dirPath: string): Promise<void> {
const entries = await new Promise<Array<{ filename: string; attrs: any }>>((resolve, reject) => {
sftp.readdir(dirPath, (err, list) => {
if (err) {
reject(err)
} else {
resolve(list)
}
})
})
for (const entry of entries) {
if (entry.filename === '.' || entry.filename === '..') continue
const entryPath = `${dirPath}/${entry.filename}`
const entryType = getFileType(entry.attrs)
if (entryType === 'directory') {
await deleteRecursive(sftp, entryPath)
} else {
await new Promise<void>((resolve, reject) => {
sftp.unlink(entryPath, (err) => {
if (err) reject(err)
else resolve()
})
})
}
}
await new Promise<void>((resolve, reject) => {
sftp.rmdir(dirPath, (err) => {
if (err) reject(err)
else resolve()
})
})
}
export async function POST(request: NextRequest) {
const requestId = generateRequestId()
try {
const authResult = await checkHybridAuth(request, { requireWorkflowId: false })
if (!authResult.success) {
logger.warn(`[${requestId}] Unauthorized SFTP delete attempt: ${authResult.error}`)
return NextResponse.json(
{ success: false, error: authResult.error || 'Authentication required' },
{ status: 401 }
)
}
logger.info(`[${requestId}] Authenticated SFTP delete request via ${authResult.authType}`, {
userId: authResult.userId,
})
const body = await request.json()
const params = DeleteSchema.parse(body)
if (!params.password && !params.privateKey) {
return NextResponse.json(
{ error: 'Either password or privateKey must be provided' },
{ status: 400 }
)
}
if (!isPathSafe(params.remotePath)) {
logger.warn(`[${requestId}] Path traversal attempt detected in remotePath`)
return NextResponse.json(
{ error: 'Invalid remote path: path traversal sequences are not allowed' },
{ status: 400 }
)
}
logger.info(`[${requestId}] Connecting to SFTP server ${params.host}:${params.port}`)
const client = await createSftpConnection({
host: params.host,
port: params.port,
username: params.username,
password: params.password,
privateKey: params.privateKey,
passphrase: params.passphrase,
})
try {
const sftp = await getSftp(client)
const remotePath = sanitizePath(params.remotePath)
logger.info(`[${requestId}] Deleting ${remotePath} (recursive: ${params.recursive})`)
const isDir = await sftpIsDirectory(sftp, remotePath)
if (isDir) {
if (params.recursive) {
await deleteRecursive(sftp, remotePath)
} else {
await new Promise<void>((resolve, reject) => {
sftp.rmdir(remotePath, (err) => {
if (err) {
if (err.message.includes('not empty')) {
reject(
new Error(
'Directory is not empty. Use recursive: true to delete non-empty directories.'
)
)
} else {
reject(err)
}
} else {
resolve()
}
})
})
}
} else {
await new Promise<void>((resolve, reject) => {
sftp.unlink(remotePath, (err) => {
if (err) {
if (err.message.includes('No such file')) {
reject(new Error(`File not found: ${remotePath}`))
} else {
reject(err)
}
} else {
resolve()
}
})
})
}
logger.info(`[${requestId}] Successfully deleted ${remotePath}`)
return NextResponse.json({
success: true,
deletedPath: remotePath,
message: `Successfully deleted ${remotePath}`,
})
} finally {
client.end()
}
} catch (error) {
if (error instanceof z.ZodError) {
logger.warn(`[${requestId}] Invalid request data`, { errors: error.errors })
return NextResponse.json(
{ error: 'Invalid request data', details: error.errors },
{ status: 400 }
)
}
const errorMessage = error instanceof Error ? error.message : 'Unknown error occurred'
logger.error(`[${requestId}] SFTP delete failed:`, error)
return NextResponse.json({ error: `SFTP delete failed: ${errorMessage}` }, { status: 500 })
}
}

View File

@@ -0,0 +1,149 @@
import path from 'path'
import { type NextRequest, NextResponse } from 'next/server'
import { z } from 'zod'
import { checkHybridAuth } from '@/lib/auth/hybrid'
import { generateRequestId } from '@/lib/core/utils/request'
import { createLogger } from '@/lib/logs/console/logger'
import { createSftpConnection, getSftp, isPathSafe, sanitizePath } from '@/app/api/tools/sftp/utils'
export const dynamic = 'force-dynamic'
const logger = createLogger('SftpDownloadAPI')
const DownloadSchema = z.object({
host: z.string().min(1, 'Host is required'),
port: z.coerce.number().int().positive().default(22),
username: z.string().min(1, 'Username is required'),
password: z.string().nullish(),
privateKey: z.string().nullish(),
passphrase: z.string().nullish(),
remotePath: z.string().min(1, 'Remote path is required'),
encoding: z.enum(['utf-8', 'base64']).default('utf-8'),
})
export async function POST(request: NextRequest) {
const requestId = generateRequestId()
try {
const authResult = await checkHybridAuth(request, { requireWorkflowId: false })
if (!authResult.success) {
logger.warn(`[${requestId}] Unauthorized SFTP download attempt: ${authResult.error}`)
return NextResponse.json(
{ success: false, error: authResult.error || 'Authentication required' },
{ status: 401 }
)
}
logger.info(`[${requestId}] Authenticated SFTP download request via ${authResult.authType}`, {
userId: authResult.userId,
})
const body = await request.json()
const params = DownloadSchema.parse(body)
if (!params.password && !params.privateKey) {
return NextResponse.json(
{ error: 'Either password or privateKey must be provided' },
{ status: 400 }
)
}
if (!isPathSafe(params.remotePath)) {
logger.warn(`[${requestId}] Path traversal attempt detected in remotePath`)
return NextResponse.json(
{ error: 'Invalid remote path: path traversal sequences are not allowed' },
{ status: 400 }
)
}
logger.info(`[${requestId}] Connecting to SFTP server ${params.host}:${params.port}`)
const client = await createSftpConnection({
host: params.host,
port: params.port,
username: params.username,
password: params.password,
privateKey: params.privateKey,
passphrase: params.passphrase,
})
try {
const sftp = await getSftp(client)
const remotePath = sanitizePath(params.remotePath)
const stats = await new Promise<{ size: number }>((resolve, reject) => {
sftp.stat(remotePath, (err, stats) => {
if (err) {
if (err.message.includes('No such file')) {
reject(new Error(`File not found: ${remotePath}`))
} else {
reject(err)
}
} else {
resolve(stats)
}
})
})
const maxSize = 50 * 1024 * 1024
if (stats.size > maxSize) {
const sizeMB = (stats.size / (1024 * 1024)).toFixed(2)
return NextResponse.json(
{ success: false, error: `File size (${sizeMB}MB) exceeds download limit of 50MB` },
{ status: 400 }
)
}
logger.info(`[${requestId}] Downloading file ${remotePath} (${stats.size} bytes)`)
const chunks: Buffer[] = []
await new Promise<void>((resolve, reject) => {
const readStream = sftp.createReadStream(remotePath)
readStream.on('data', (chunk: Buffer) => {
chunks.push(chunk)
})
readStream.on('end', () => resolve())
readStream.on('error', reject)
})
const buffer = Buffer.concat(chunks)
const fileName = path.basename(remotePath)
let content: string
if (params.encoding === 'base64') {
content = buffer.toString('base64')
} else {
content = buffer.toString('utf-8')
}
logger.info(`[${requestId}] Downloaded ${fileName} (${buffer.length} bytes)`)
return NextResponse.json({
success: true,
fileName,
content,
size: buffer.length,
encoding: params.encoding,
message: `Successfully downloaded ${fileName}`,
})
} finally {
client.end()
}
} catch (error) {
if (error instanceof z.ZodError) {
logger.warn(`[${requestId}] Invalid request data`, { errors: error.errors })
return NextResponse.json(
{ error: 'Invalid request data', details: error.errors },
{ status: 400 }
)
}
const errorMessage = error instanceof Error ? error.message : 'Unknown error occurred'
logger.error(`[${requestId}] SFTP download failed:`, error)
return NextResponse.json({ error: `SFTP download failed: ${errorMessage}` }, { status: 500 })
}
}

View File

@@ -0,0 +1,156 @@
import { type NextRequest, NextResponse } from 'next/server'
import { z } from 'zod'
import { checkHybridAuth } from '@/lib/auth/hybrid'
import { generateRequestId } from '@/lib/core/utils/request'
import { createLogger } from '@/lib/logs/console/logger'
import {
createSftpConnection,
getFileType,
getSftp,
isPathSafe,
parsePermissions,
sanitizePath,
} from '@/app/api/tools/sftp/utils'
export const dynamic = 'force-dynamic'
const logger = createLogger('SftpListAPI')
const ListSchema = z.object({
host: z.string().min(1, 'Host is required'),
port: z.coerce.number().int().positive().default(22),
username: z.string().min(1, 'Username is required'),
password: z.string().nullish(),
privateKey: z.string().nullish(),
passphrase: z.string().nullish(),
remotePath: z.string().min(1, 'Remote path is required'),
detailed: z.boolean().default(false),
})
export async function POST(request: NextRequest) {
const requestId = generateRequestId()
try {
const authResult = await checkHybridAuth(request, { requireWorkflowId: false })
if (!authResult.success) {
logger.warn(`[${requestId}] Unauthorized SFTP list attempt: ${authResult.error}`)
return NextResponse.json(
{ success: false, error: authResult.error || 'Authentication required' },
{ status: 401 }
)
}
logger.info(`[${requestId}] Authenticated SFTP list request via ${authResult.authType}`, {
userId: authResult.userId,
})
const body = await request.json()
const params = ListSchema.parse(body)
if (!params.password && !params.privateKey) {
return NextResponse.json(
{ error: 'Either password or privateKey must be provided' },
{ status: 400 }
)
}
if (!isPathSafe(params.remotePath)) {
logger.warn(`[${requestId}] Path traversal attempt detected in remotePath`)
return NextResponse.json(
{ error: 'Invalid remote path: path traversal sequences are not allowed' },
{ status: 400 }
)
}
logger.info(`[${requestId}] Connecting to SFTP server ${params.host}:${params.port}`)
const client = await createSftpConnection({
host: params.host,
port: params.port,
username: params.username,
password: params.password,
privateKey: params.privateKey,
passphrase: params.passphrase,
})
try {
const sftp = await getSftp(client)
const remotePath = sanitizePath(params.remotePath)
logger.info(`[${requestId}] Listing directory ${remotePath}`)
const fileList = await new Promise<Array<{ filename: string; longname: string; attrs: any }>>(
(resolve, reject) => {
sftp.readdir(remotePath, (err, list) => {
if (err) {
if (err.message.includes('No such file')) {
reject(new Error(`Directory not found: ${remotePath}`))
} else {
reject(err)
}
} else {
resolve(list)
}
})
}
)
const entries = fileList
.filter((item) => item.filename !== '.' && item.filename !== '..')
.map((item) => {
const entry: {
name: string
type: 'file' | 'directory' | 'symlink' | 'other'
size?: number
permissions?: string
modifiedAt?: string
} = {
name: item.filename,
type: getFileType(item.attrs),
}
if (params.detailed) {
entry.size = item.attrs.size
entry.permissions = parsePermissions(item.attrs.mode)
if (item.attrs.mtime) {
entry.modifiedAt = new Date(item.attrs.mtime * 1000).toISOString()
}
}
return entry
})
entries.sort((a, b) => {
if (a.type === 'directory' && b.type !== 'directory') return -1
if (a.type !== 'directory' && b.type === 'directory') return 1
return a.name.localeCompare(b.name)
})
logger.info(`[${requestId}] Listed ${entries.length} entries in ${remotePath}`)
return NextResponse.json({
success: true,
path: remotePath,
entries,
count: entries.length,
message: `Found ${entries.length} entries in ${remotePath}`,
})
} finally {
client.end()
}
} catch (error) {
if (error instanceof z.ZodError) {
logger.warn(`[${requestId}] Invalid request data`, { errors: error.errors })
return NextResponse.json(
{ error: 'Invalid request data', details: error.errors },
{ status: 400 }
)
}
const errorMessage = error instanceof Error ? error.message : 'Unknown error occurred'
logger.error(`[${requestId}] SFTP list failed:`, error)
return NextResponse.json({ error: `SFTP list failed: ${errorMessage}` }, { status: 500 })
}
}

View File

@@ -0,0 +1,168 @@
import { type NextRequest, NextResponse } from 'next/server'
import type { SFTPWrapper } from 'ssh2'
import { z } from 'zod'
import { checkHybridAuth } from '@/lib/auth/hybrid'
import { generateRequestId } from '@/lib/core/utils/request'
import { createLogger } from '@/lib/logs/console/logger'
import {
createSftpConnection,
getSftp,
isPathSafe,
sanitizePath,
sftpExists,
} from '@/app/api/tools/sftp/utils'
export const dynamic = 'force-dynamic'
const logger = createLogger('SftpMkdirAPI')
const MkdirSchema = z.object({
host: z.string().min(1, 'Host is required'),
port: z.coerce.number().int().positive().default(22),
username: z.string().min(1, 'Username is required'),
password: z.string().nullish(),
privateKey: z.string().nullish(),
passphrase: z.string().nullish(),
remotePath: z.string().min(1, 'Remote path is required'),
recursive: z.boolean().default(false),
})
/**
* Creates directory recursively (like mkdir -p)
*/
async function mkdirRecursive(sftp: SFTPWrapper, dirPath: string): Promise<void> {
const parts = dirPath.split('/').filter(Boolean)
let currentPath = dirPath.startsWith('/') ? '' : ''
for (const part of parts) {
currentPath = currentPath
? `${currentPath}/${part}`
: dirPath.startsWith('/')
? `/${part}`
: part
const exists = await sftpExists(sftp, currentPath)
if (!exists) {
await new Promise<void>((resolve, reject) => {
sftp.mkdir(currentPath, (err) => {
if (err && !err.message.includes('already exists')) {
reject(err)
} else {
resolve()
}
})
})
}
}
}
export async function POST(request: NextRequest) {
const requestId = generateRequestId()
try {
const authResult = await checkHybridAuth(request, { requireWorkflowId: false })
if (!authResult.success) {
logger.warn(`[${requestId}] Unauthorized SFTP mkdir attempt: ${authResult.error}`)
return NextResponse.json(
{ success: false, error: authResult.error || 'Authentication required' },
{ status: 401 }
)
}
logger.info(`[${requestId}] Authenticated SFTP mkdir request via ${authResult.authType}`, {
userId: authResult.userId,
})
const body = await request.json()
const params = MkdirSchema.parse(body)
if (!params.password && !params.privateKey) {
return NextResponse.json(
{ error: 'Either password or privateKey must be provided' },
{ status: 400 }
)
}
if (!isPathSafe(params.remotePath)) {
logger.warn(`[${requestId}] Path traversal attempt detected in remotePath`)
return NextResponse.json(
{ error: 'Invalid remote path: path traversal sequences are not allowed' },
{ status: 400 }
)
}
logger.info(`[${requestId}] Connecting to SFTP server ${params.host}:${params.port}`)
const client = await createSftpConnection({
host: params.host,
port: params.port,
username: params.username,
password: params.password,
privateKey: params.privateKey,
passphrase: params.passphrase,
})
try {
const sftp = await getSftp(client)
const remotePath = sanitizePath(params.remotePath)
logger.info(
`[${requestId}] Creating directory ${remotePath} (recursive: ${params.recursive})`
)
if (params.recursive) {
await mkdirRecursive(sftp, remotePath)
} else {
const exists = await sftpExists(sftp, remotePath)
if (exists) {
return NextResponse.json(
{ error: `Directory already exists: ${remotePath}` },
{ status: 409 }
)
}
await new Promise<void>((resolve, reject) => {
sftp.mkdir(remotePath, (err) => {
if (err) {
if (err.message.includes('No such file')) {
reject(
new Error(
'Parent directory does not exist. Use recursive: true to create parent directories.'
)
)
} else {
reject(err)
}
} else {
resolve()
}
})
})
}
logger.info(`[${requestId}] Successfully created directory ${remotePath}`)
return NextResponse.json({
success: true,
createdPath: remotePath,
message: `Successfully created directory ${remotePath}`,
})
} finally {
client.end()
}
} catch (error) {
if (error instanceof z.ZodError) {
logger.warn(`[${requestId}] Invalid request data`, { errors: error.errors })
return NextResponse.json(
{ error: 'Invalid request data', details: error.errors },
{ status: 400 }
)
}
const errorMessage = error instanceof Error ? error.message : 'Unknown error occurred'
logger.error(`[${requestId}] SFTP mkdir failed:`, error)
return NextResponse.json({ error: `SFTP mkdir failed: ${errorMessage}` }, { status: 500 })
}
}

View File

@@ -0,0 +1,242 @@
import { type NextRequest, NextResponse } from 'next/server'
import { z } from 'zod'
import { checkHybridAuth } from '@/lib/auth/hybrid'
import { generateRequestId } from '@/lib/core/utils/request'
import { createLogger } from '@/lib/logs/console/logger'
import { processFilesToUserFiles } from '@/lib/uploads/utils/file-utils'
import { downloadFileFromStorage } from '@/lib/uploads/utils/file-utils.server'
import {
createSftpConnection,
getSftp,
isPathSafe,
sanitizeFileName,
sanitizePath,
sftpExists,
} from '@/app/api/tools/sftp/utils'
export const dynamic = 'force-dynamic'
const logger = createLogger('SftpUploadAPI')
const UploadSchema = z.object({
host: z.string().min(1, 'Host is required'),
port: z.coerce.number().int().positive().default(22),
username: z.string().min(1, 'Username is required'),
password: z.string().nullish(),
privateKey: z.string().nullish(),
passphrase: z.string().nullish(),
remotePath: z.string().min(1, 'Remote path is required'),
files: z
.union([z.array(z.any()), z.string(), z.number(), z.null(), z.undefined()])
.transform((val) => {
if (Array.isArray(val)) return val
if (val === null || val === undefined || val === '') return undefined
return undefined
})
.nullish(),
fileContent: z.string().nullish(),
fileName: z.string().nullish(),
overwrite: z.boolean().default(true),
permissions: z.string().nullish(),
})
export async function POST(request: NextRequest) {
const requestId = generateRequestId()
try {
const authResult = await checkHybridAuth(request, { requireWorkflowId: false })
if (!authResult.success) {
logger.warn(`[${requestId}] Unauthorized SFTP upload attempt: ${authResult.error}`)
return NextResponse.json(
{ success: false, error: authResult.error || 'Authentication required' },
{ status: 401 }
)
}
logger.info(`[${requestId}] Authenticated SFTP upload request via ${authResult.authType}`, {
userId: authResult.userId,
})
const body = await request.json()
const params = UploadSchema.parse(body)
if (!params.password && !params.privateKey) {
return NextResponse.json(
{ error: 'Either password or privateKey must be provided' },
{ status: 400 }
)
}
const hasFiles = params.files && params.files.length > 0
const hasDirectContent = params.fileContent && params.fileName
if (!hasFiles && !hasDirectContent) {
return NextResponse.json(
{ error: 'Either files or fileContent with fileName must be provided' },
{ status: 400 }
)
}
if (!isPathSafe(params.remotePath)) {
logger.warn(`[${requestId}] Path traversal attempt detected in remotePath`)
return NextResponse.json(
{ error: 'Invalid remote path: path traversal sequences are not allowed' },
{ status: 400 }
)
}
logger.info(`[${requestId}] Connecting to SFTP server ${params.host}:${params.port}`)
const client = await createSftpConnection({
host: params.host,
port: params.port,
username: params.username,
password: params.password,
privateKey: params.privateKey,
passphrase: params.passphrase,
})
try {
const sftp = await getSftp(client)
const remotePath = sanitizePath(params.remotePath)
const uploadedFiles: Array<{ name: string; remotePath: string; size: number }> = []
if (hasFiles) {
const rawFiles = params.files!
logger.info(`[${requestId}] Processing ${rawFiles.length} file(s) for upload`)
const userFiles = processFilesToUserFiles(rawFiles, requestId, logger)
const totalSize = userFiles.reduce((sum, file) => sum + file.size, 0)
const maxSize = 100 * 1024 * 1024
if (totalSize > maxSize) {
const sizeMB = (totalSize / (1024 * 1024)).toFixed(2)
return NextResponse.json(
{ success: false, error: `Total file size (${sizeMB}MB) exceeds limit of 100MB` },
{ status: 400 }
)
}
for (const file of userFiles) {
try {
logger.info(
`[${requestId}] Downloading file for upload: ${file.name} (${file.size} bytes)`
)
const buffer = await downloadFileFromStorage(file, requestId, logger)
const safeFileName = sanitizeFileName(file.name)
const fullRemotePath = remotePath.endsWith('/')
? `${remotePath}${safeFileName}`
: `${remotePath}/${safeFileName}`
const sanitizedRemotePath = sanitizePath(fullRemotePath)
if (!params.overwrite) {
const exists = await sftpExists(sftp, sanitizedRemotePath)
if (exists) {
logger.warn(`[${requestId}] File ${sanitizedRemotePath} already exists, skipping`)
continue
}
}
await new Promise<void>((resolve, reject) => {
const writeStream = sftp.createWriteStream(sanitizedRemotePath, {
mode: params.permissions ? Number.parseInt(params.permissions, 8) : 0o644,
})
writeStream.on('error', reject)
writeStream.on('close', () => resolve())
writeStream.end(buffer)
})
uploadedFiles.push({
name: safeFileName,
remotePath: sanitizedRemotePath,
size: buffer.length,
})
logger.info(`[${requestId}] Uploaded ${safeFileName} to ${sanitizedRemotePath}`)
} catch (error) {
logger.error(`[${requestId}] Failed to upload file ${file.name}:`, error)
throw new Error(
`Failed to upload file "${file.name}": ${error instanceof Error ? error.message : 'Unknown error'}`
)
}
}
}
if (hasDirectContent) {
const safeFileName = sanitizeFileName(params.fileName!)
const fullRemotePath = remotePath.endsWith('/')
? `${remotePath}${safeFileName}`
: `${remotePath}/${safeFileName}`
const sanitizedRemotePath = sanitizePath(fullRemotePath)
if (!params.overwrite) {
const exists = await sftpExists(sftp, sanitizedRemotePath)
if (exists) {
return NextResponse.json(
{ error: 'File already exists and overwrite is disabled' },
{ status: 409 }
)
}
}
let content: Buffer
try {
content = Buffer.from(params.fileContent!, 'base64')
const reEncoded = content.toString('base64')
if (reEncoded !== params.fileContent) {
content = Buffer.from(params.fileContent!, 'utf-8')
}
} catch {
content = Buffer.from(params.fileContent!, 'utf-8')
}
await new Promise<void>((resolve, reject) => {
const writeStream = sftp.createWriteStream(sanitizedRemotePath, {
mode: params.permissions ? Number.parseInt(params.permissions, 8) : 0o644,
})
writeStream.on('error', reject)
writeStream.on('close', () => resolve())
writeStream.end(content)
})
uploadedFiles.push({
name: safeFileName,
remotePath: sanitizedRemotePath,
size: content.length,
})
logger.info(`[${requestId}] Uploaded direct content to ${sanitizedRemotePath}`)
}
logger.info(`[${requestId}] SFTP upload completed: ${uploadedFiles.length} file(s)`)
return NextResponse.json({
success: true,
uploadedFiles,
message: `Successfully uploaded ${uploadedFiles.length} file(s)`,
})
} finally {
client.end()
}
} catch (error) {
if (error instanceof z.ZodError) {
logger.warn(`[${requestId}] Invalid request data`, { errors: error.errors })
return NextResponse.json(
{ error: 'Invalid request data', details: error.errors },
{ status: 400 }
)
}
const errorMessage = error instanceof Error ? error.message : 'Unknown error occurred'
logger.error(`[${requestId}] SFTP upload failed:`, error)
return NextResponse.json({ error: `SFTP upload failed: ${errorMessage}` }, { status: 500 })
}
}

View File

@@ -0,0 +1,275 @@
import { type Attributes, Client, type ConnectConfig, type SFTPWrapper } from 'ssh2'
const S_IFMT = 0o170000
const S_IFDIR = 0o040000
const S_IFREG = 0o100000
const S_IFLNK = 0o120000
export interface SftpConnectionConfig {
host: string
port: number
username: string
password?: string | null
privateKey?: string | null
passphrase?: string | null
timeout?: number
keepaliveInterval?: number
readyTimeout?: number
}
/**
* Formats SSH/SFTP errors with helpful troubleshooting context
*/
function formatSftpError(err: Error, config: { host: string; port: number }): Error {
const errorMessage = err.message.toLowerCase()
const { host, port } = config
if (errorMessage.includes('econnrefused') || errorMessage.includes('connection refused')) {
return new Error(
`Connection refused to ${host}:${port}. ` +
`Please verify: (1) SSH/SFTP server is running, ` +
`(2) Port ${port} is correct, ` +
`(3) Firewall allows connections.`
)
}
if (errorMessage.includes('econnreset') || errorMessage.includes('connection reset')) {
return new Error(
`Connection reset by ${host}:${port}. ` +
`This usually means: (1) Wrong port number, ` +
`(2) Server rejected the connection, ` +
`(3) Network/firewall interrupted the connection.`
)
}
if (errorMessage.includes('etimedout') || errorMessage.includes('timeout')) {
return new Error(
`Connection timed out to ${host}:${port}. ` +
`Please verify: (1) Host is reachable, ` +
`(2) No firewall is blocking the connection, ` +
`(3) The SFTP server is responding.`
)
}
if (errorMessage.includes('enotfound') || errorMessage.includes('getaddrinfo')) {
return new Error(
`Could not resolve hostname "${host}". Please verify the hostname or IP address is correct.`
)
}
if (errorMessage.includes('authentication') || errorMessage.includes('auth')) {
return new Error(
`Authentication failed on ${host}:${port}. ` +
`Please verify: (1) Username is correct, ` +
`(2) Password or private key is valid, ` +
`(3) User has SFTP access on the server.`
)
}
if (
errorMessage.includes('key') &&
(errorMessage.includes('parse') || errorMessage.includes('invalid'))
) {
return new Error(
`Invalid private key format. ` +
`Please ensure you're using a valid OpenSSH private key ` +
`(starts with "-----BEGIN" and ends with "-----END").`
)
}
if (errorMessage.includes('host key') || errorMessage.includes('hostkey')) {
return new Error(
`Host key verification issue for ${host}. ` +
`This may be the first connection or the server's key has changed.`
)
}
return new Error(`SFTP connection to ${host}:${port} failed: ${err.message}`)
}
/**
* Creates an SSH connection for SFTP using the provided configuration.
* Uses ssh2 library defaults which align with OpenSSH standards.
*/
export function createSftpConnection(config: SftpConnectionConfig): Promise<Client> {
return new Promise((resolve, reject) => {
const client = new Client()
const port = config.port || 22
const host = config.host
if (!host || host.trim() === '') {
reject(new Error('Host is required. Please provide a valid hostname or IP address.'))
return
}
const hasPassword = config.password && config.password.trim() !== ''
const hasPrivateKey = config.privateKey && config.privateKey.trim() !== ''
if (!hasPassword && !hasPrivateKey) {
reject(new Error('Authentication required. Please provide either a password or private key.'))
return
}
const connectConfig: ConnectConfig = {
host: host.trim(),
port,
username: config.username,
}
if (config.readyTimeout !== undefined) {
connectConfig.readyTimeout = config.readyTimeout
}
if (config.keepaliveInterval !== undefined) {
connectConfig.keepaliveInterval = config.keepaliveInterval
}
if (hasPrivateKey) {
connectConfig.privateKey = config.privateKey!
if (config.passphrase && config.passphrase.trim() !== '') {
connectConfig.passphrase = config.passphrase
}
} else if (hasPassword) {
connectConfig.password = config.password!
}
client.on('ready', () => {
resolve(client)
})
client.on('error', (err) => {
reject(formatSftpError(err, { host, port }))
})
try {
client.connect(connectConfig)
} catch (err) {
reject(formatSftpError(err instanceof Error ? err : new Error(String(err)), { host, port }))
}
})
}
/**
* Gets SFTP subsystem from SSH client
*/
export function getSftp(client: Client): Promise<SFTPWrapper> {
return new Promise((resolve, reject) => {
client.sftp((err, sftp) => {
if (err) {
reject(new Error(`Failed to start SFTP session: ${err.message}`))
} else {
resolve(sftp)
}
})
})
}
/**
* Sanitizes a remote path to prevent path traversal attacks.
* Removes null bytes, normalizes path separators, and collapses traversal sequences.
* Based on OWASP Path Traversal prevention guidelines.
*/
export function sanitizePath(path: string): string {
let sanitized = path
sanitized = sanitized.replace(/\0/g, '')
sanitized = decodeURIComponent(sanitized)
sanitized = sanitized.replace(/\\/g, '/')
sanitized = sanitized.replace(/\/+/g, '/')
sanitized = sanitized.trim()
return sanitized
}
/**
* Sanitizes a filename to prevent path traversal and injection attacks.
* Removes directory traversal sequences, path separators, null bytes, and dangerous patterns.
* Based on OWASP Input Validation Cheat Sheet recommendations.
*/
export function sanitizeFileName(fileName: string): string {
let sanitized = fileName
sanitized = sanitized.replace(/\0/g, '')
try {
sanitized = decodeURIComponent(sanitized)
} catch {
// Keep original if decode fails (malformed encoding)
}
sanitized = sanitized.replace(/\.\.[/\\]?/g, '')
sanitized = sanitized.replace(/[/\\]/g, '_')
sanitized = sanitized.replace(/^\.+/, '')
sanitized = sanitized.replace(/[\x00-\x1f\x7f]/g, '')
sanitized = sanitized.trim()
return sanitized || 'unnamed_file'
}
/**
* Validates that a path doesn't contain traversal sequences.
* Returns true if the path is safe, false if it contains potential traversal attacks.
*/
export function isPathSafe(path: string): boolean {
const normalizedPath = path.replace(/\\/g, '/')
if (normalizedPath.includes('../') || normalizedPath.includes('..\\')) {
return false
}
try {
const decoded = decodeURIComponent(normalizedPath)
if (decoded.includes('../') || decoded.includes('..\\')) {
return false
}
} catch {
return false
}
if (normalizedPath.includes('\0')) {
return false
}
return true
}
/**
* Parses file permissions from mode bits to octal string representation.
*/
export function parsePermissions(mode: number): string {
return `0${(mode & 0o777).toString(8)}`
}
/**
* Determines file type from SFTP attributes mode bits.
*/
export function getFileType(attrs: Attributes): 'file' | 'directory' | 'symlink' | 'other' {
const fileType = attrs.mode & S_IFMT
if (fileType === S_IFDIR) return 'directory'
if (fileType === S_IFREG) return 'file'
if (fileType === S_IFLNK) return 'symlink'
return 'other'
}
/**
* Checks if a path exists on the SFTP server.
*/
export function sftpExists(sftp: SFTPWrapper, path: string): Promise<boolean> {
return new Promise((resolve) => {
sftp.stat(path, (err) => {
resolve(!err)
})
})
}
/**
* Checks if a path is a directory on the SFTP server.
*/
export function sftpIsDirectory(sftp: SFTPWrapper, path: string): Promise<boolean> {
return new Promise((resolve) => {
sftp.stat(path, (err, stats) => {
if (err) {
resolve(false)
} else {
resolve(getFileType(stats) === 'directory')
}
})
})
}

View File

@@ -0,0 +1,306 @@
import { SftpIcon } from '@/components/icons'
import type { BlockConfig } from '@/blocks/types'
import { AuthMode } from '@/blocks/types'
import type { SftpUploadResult } from '@/tools/sftp/types'
export const SftpBlock: BlockConfig<SftpUploadResult> = {
type: 'sftp',
name: 'SFTP',
description: 'Transfer files via SFTP (SSH File Transfer Protocol)',
longDescription:
'Upload, download, list, and manage files on remote servers via SFTP. Supports both password and private key authentication for secure file transfers.',
docsLink: 'https://docs.sim.ai/tools/sftp',
category: 'tools',
bgColor: '#2D3748',
icon: SftpIcon,
authMode: AuthMode.ApiKey,
subBlocks: [
{
id: 'operation',
title: 'Operation',
type: 'dropdown',
options: [
{ label: 'Upload Files', id: 'sftp_upload' },
{ label: 'Create File', id: 'sftp_create' },
{ label: 'Download File', id: 'sftp_download' },
{ label: 'List Directory', id: 'sftp_list' },
{ label: 'Delete File/Directory', id: 'sftp_delete' },
{ label: 'Create Directory', id: 'sftp_mkdir' },
],
value: () => 'sftp_upload',
},
{
id: 'host',
title: 'SFTP Host',
type: 'short-input',
placeholder: 'sftp.example.com or 192.168.1.100',
required: true,
},
{
id: 'port',
title: 'SFTP Port',
type: 'short-input',
placeholder: '22',
value: () => '22',
},
{
id: 'username',
title: 'Username',
type: 'short-input',
placeholder: 'sftp-user',
required: true,
},
{
id: 'authMethod',
title: 'Authentication Method',
type: 'dropdown',
options: [
{ label: 'Password', id: 'password' },
{ label: 'Private Key', id: 'privateKey' },
],
value: () => 'password',
},
{
id: 'password',
title: 'Password',
type: 'short-input',
password: true,
placeholder: 'Your SFTP password',
condition: { field: 'authMethod', value: 'password' },
},
{
id: 'privateKey',
title: 'Private Key',
type: 'code',
placeholder: '-----BEGIN OPENSSH PRIVATE KEY-----\n...',
condition: { field: 'authMethod', value: 'privateKey' },
},
{
id: 'passphrase',
title: 'Passphrase',
type: 'short-input',
password: true,
placeholder: 'Passphrase for encrypted key (optional)',
condition: { field: 'authMethod', value: 'privateKey' },
},
{
id: 'remotePath',
title: 'Remote Path',
type: 'short-input',
placeholder: '/home/user/uploads',
required: true,
},
{
id: 'uploadFiles',
title: 'Files to Upload',
type: 'file-upload',
canonicalParamId: 'files',
placeholder: 'Select files to upload',
mode: 'basic',
multiple: true,
required: false,
condition: { field: 'operation', value: 'sftp_upload' },
},
{
id: 'files',
title: 'File Reference',
type: 'short-input',
canonicalParamId: 'files',
placeholder: 'Reference file from previous block (e.g., {{block_name.file}})',
mode: 'advanced',
required: false,
condition: { field: 'operation', value: 'sftp_upload' },
},
{
id: 'overwrite',
title: 'Overwrite Existing Files',
type: 'switch',
defaultValue: true,
condition: { field: 'operation', value: ['sftp_upload', 'sftp_create'] },
},
{
id: 'permissions',
title: 'File Permissions',
type: 'short-input',
placeholder: '0644',
condition: { field: 'operation', value: ['sftp_upload', 'sftp_create'] },
mode: 'advanced',
},
{
id: 'fileName',
title: 'File Name',
type: 'short-input',
placeholder: 'filename.txt',
condition: { field: 'operation', value: 'sftp_create' },
required: true,
},
{
id: 'fileContent',
title: 'File Content',
type: 'code',
placeholder: 'Text content to write to the file',
condition: { field: 'operation', value: 'sftp_create' },
required: true,
},
{
id: 'encoding',
title: 'Output Encoding',
type: 'dropdown',
options: [
{ label: 'UTF-8 (Text)', id: 'utf-8' },
{ label: 'Base64 (Binary)', id: 'base64' },
],
value: () => 'utf-8',
condition: { field: 'operation', value: 'sftp_download' },
},
{
id: 'detailed',
title: 'Show Detailed Info',
type: 'switch',
defaultValue: false,
condition: { field: 'operation', value: 'sftp_list' },
},
{
id: 'recursive',
title: 'Recursive Delete',
type: 'switch',
defaultValue: false,
condition: { field: 'operation', value: 'sftp_delete' },
},
{
id: 'mkdirRecursive',
title: 'Create Parent Directories',
type: 'switch',
defaultValue: true,
condition: { field: 'operation', value: 'sftp_mkdir' },
},
],
tools: {
access: ['sftp_upload', 'sftp_download', 'sftp_list', 'sftp_delete', 'sftp_mkdir'],
config: {
tool: (params) => {
const operation = params.operation || 'sftp_upload'
if (operation === 'sftp_create') return 'sftp_upload'
return operation
},
params: (params) => {
const connectionConfig: Record<string, unknown> = {
host: params.host,
port:
typeof params.port === 'string' ? Number.parseInt(params.port, 10) : params.port || 22,
username: params.username,
}
if (params.authMethod === 'privateKey') {
connectionConfig.privateKey = params.privateKey
if (params.passphrase) {
connectionConfig.passphrase = params.passphrase
}
} else {
connectionConfig.password = params.password
}
const operation = params.operation || 'sftp_upload'
switch (operation) {
case 'sftp_upload':
return {
...connectionConfig,
remotePath: params.remotePath,
files: params.files,
overwrite: params.overwrite !== false,
permissions: params.permissions,
}
case 'sftp_create':
return {
...connectionConfig,
remotePath: params.remotePath,
fileContent: params.fileContent,
fileName: params.fileName,
overwrite: params.overwrite !== false,
permissions: params.permissions,
}
case 'sftp_download':
return {
...connectionConfig,
remotePath: params.remotePath,
encoding: params.encoding || 'utf-8',
}
case 'sftp_list':
return {
...connectionConfig,
remotePath: params.remotePath,
detailed: params.detailed || false,
}
case 'sftp_delete':
return {
...connectionConfig,
remotePath: params.remotePath,
recursive: params.recursive || false,
}
case 'sftp_mkdir':
return {
...connectionConfig,
remotePath: params.remotePath,
recursive: params.mkdirRecursive !== false,
}
default:
return {
...connectionConfig,
remotePath: params.remotePath,
}
}
},
},
},
inputs: {
operation: { type: 'string', description: 'SFTP operation to perform' },
host: { type: 'string', description: 'SFTP server hostname' },
port: { type: 'number', description: 'SFTP server port' },
username: { type: 'string', description: 'SFTP username' },
authMethod: { type: 'string', description: 'Authentication method (password or privateKey)' },
password: { type: 'string', description: 'Password for authentication' },
privateKey: { type: 'string', description: 'Private key for authentication' },
passphrase: { type: 'string', description: 'Passphrase for encrypted key' },
remotePath: { type: 'string', description: 'Remote path on the SFTP server' },
files: { type: 'array', description: 'Files to upload (UserFile array)' },
fileContent: { type: 'string', description: 'Direct content to upload' },
fileName: { type: 'string', description: 'File name for direct content' },
overwrite: { type: 'boolean', description: 'Overwrite existing files' },
permissions: { type: 'string', description: 'File permissions (e.g., 0644)' },
encoding: { type: 'string', description: 'Output encoding for download' },
detailed: { type: 'boolean', description: 'Show detailed file info' },
recursive: { type: 'boolean', description: 'Recursive delete' },
mkdirRecursive: { type: 'boolean', description: 'Create parent directories' },
},
outputs: {
success: { type: 'boolean', description: 'Whether the operation was successful' },
uploadedFiles: { type: 'json', description: 'Array of uploaded file details' },
fileName: { type: 'string', description: 'Downloaded file name' },
content: { type: 'string', description: 'Downloaded file content' },
size: { type: 'number', description: 'File size in bytes' },
entries: { type: 'json', description: 'Directory listing entries' },
count: { type: 'number', description: 'Number of entries' },
deletedPath: { type: 'string', description: 'Path that was deleted' },
createdPath: { type: 'string', description: 'Directory that was created' },
message: { type: 'string', description: 'Operation status message' },
error: { type: 'string', description: 'Error message if operation failed' },
},
}

View File

@@ -11,7 +11,7 @@ export const SmtpBlock: BlockConfig<SmtpSendMailResult> = {
'Send emails using any SMTP server (Gmail, Outlook, custom servers, etc.). Configure SMTP connection settings and send emails with full control over content, recipients, and attachments.',
docsLink: 'https://docs.sim.ai/tools/smtp',
category: 'tools',
bgColor: '#4A5568',
bgColor: '#2D3748',
icon: SmtpIcon,
authMode: AuthMode.ApiKey,

View File

@@ -96,6 +96,7 @@ import { SearchBlock } from '@/blocks/blocks/search'
import { SendGridBlock } from '@/blocks/blocks/sendgrid'
import { SentryBlock } from '@/blocks/blocks/sentry'
import { SerperBlock } from '@/blocks/blocks/serper'
import { SftpBlock } from '@/blocks/blocks/sftp'
import { SharepointBlock } from '@/blocks/blocks/sharepoint'
import { ShopifyBlock } from '@/blocks/blocks/shopify'
import { SlackBlock } from '@/blocks/blocks/slack'
@@ -240,6 +241,7 @@ export const registry: Record<string, BlockConfig> = {
shopify: ShopifyBlock,
slack: SlackBlock,
smtp: SmtpBlock,
sftp: SftpBlock,
ssh: SSHBlock,
stagehand: StagehandBlock,
stagehand_agent: StagehandAgentBlock,

View File

@@ -3798,6 +3798,23 @@ export function SshIcon(props: SVGProps<SVGSVGElement>) {
)
}
export function SftpIcon(props: SVGProps<SVGSVGElement>) {
return (
<svg
{...props}
xmlns='http://www.w3.org/2000/svg'
viewBox='0 0 32 32'
width='32px'
height='32px'
>
<path
d='M 6 3 L 6 29 L 26 29 L 26 9.59375 L 25.71875 9.28125 L 19.71875 3.28125 L 19.40625 3 Z M 8 5 L 18 5 L 18 11 L 24 11 L 24 27 L 8 27 Z M 20 6.4375 L 22.5625 9 L 20 9 Z'
fill='currentColor'
/>
</svg>
)
}
export function ApifyIcon(props: SVGProps<SVGSVGElement>) {
return (
<svg

View File

@@ -996,6 +996,13 @@ import {
updateProjectTool,
} from '@/tools/sentry'
import { serperSearchTool } from '@/tools/serper'
import {
sftpDeleteTool,
sftpDownloadTool,
sftpListTool,
sftpMkdirTool,
sftpUploadTool,
} from '@/tools/sftp'
import {
sharepointAddListItemTool,
sharepointCreateListTool,
@@ -1372,6 +1379,11 @@ export const tools: Record<string, ToolConfig> = {
sendgrid_delete_template: sendGridDeleteTemplateTool,
sendgrid_create_template_version: sendGridCreateTemplateVersionTool,
smtp_send_mail: smtpSendMailTool,
sftp_upload: sftpUploadTool,
sftp_download: sftpDownloadTool,
sftp_list: sftpListTool,
sftp_delete: sftpDeleteTool,
sftp_mkdir: sftpMkdirTool,
ssh_execute_command: sshExecuteCommandTool,
ssh_execute_script: sshExecuteScriptTool,
ssh_check_command_exists: sshCheckCommandExistsTool,

View File

@@ -0,0 +1,107 @@
import type { SftpDeleteParams, SftpDeleteResult } from '@/tools/sftp/types'
import type { ToolConfig } from '@/tools/types'
export const sftpDeleteTool: ToolConfig<SftpDeleteParams, SftpDeleteResult> = {
id: 'sftp_delete',
name: 'SFTP Delete',
description: 'Delete a file or directory on a remote SFTP server',
version: '1.0.0',
params: {
host: {
type: 'string',
required: true,
visibility: 'user-only',
description: 'SFTP server hostname or IP address',
},
port: {
type: 'number',
required: true,
visibility: 'user-only',
description: 'SFTP server port (default: 22)',
},
username: {
type: 'string',
required: true,
visibility: 'user-only',
description: 'SFTP username',
},
password: {
type: 'string',
required: false,
visibility: 'user-only',
description: 'Password for authentication (if not using private key)',
},
privateKey: {
type: 'string',
required: false,
visibility: 'user-only',
description: 'Private key for authentication (OpenSSH format)',
},
passphrase: {
type: 'string',
required: false,
visibility: 'user-only',
description: 'Passphrase for encrypted private key',
},
remotePath: {
type: 'string',
required: true,
visibility: 'user-or-llm',
description: 'Path to the file or directory to delete',
},
recursive: {
type: 'boolean',
required: false,
visibility: 'user-only',
description: 'Delete directories recursively',
},
},
request: {
url: '/api/tools/sftp/delete',
method: 'POST',
headers: () => ({
'Content-Type': 'application/json',
}),
body: (params) => ({
host: params.host,
port: Number(params.port) || 22,
username: params.username,
password: params.password,
privateKey: params.privateKey,
passphrase: params.passphrase,
remotePath: params.remotePath,
recursive: params.recursive || false,
}),
},
transformResponse: async (response: Response) => {
const data = await response.json()
if (!response.ok) {
return {
success: false,
output: {
success: false,
},
error: data.error || 'SFTP delete failed',
}
}
return {
success: true,
output: {
success: true,
deletedPath: data.deletedPath,
message: data.message,
},
}
},
outputs: {
success: { type: 'boolean', description: 'Whether the deletion was successful' },
deletedPath: { type: 'string', description: 'Path that was deleted' },
message: { type: 'string', description: 'Operation status message' },
},
}

View File

@@ -0,0 +1,113 @@
import type { SftpDownloadParams, SftpDownloadResult } from '@/tools/sftp/types'
import type { ToolConfig } from '@/tools/types'
export const sftpDownloadTool: ToolConfig<SftpDownloadParams, SftpDownloadResult> = {
id: 'sftp_download',
name: 'SFTP Download',
description: 'Download a file from a remote SFTP server',
version: '1.0.0',
params: {
host: {
type: 'string',
required: true,
visibility: 'user-only',
description: 'SFTP server hostname or IP address',
},
port: {
type: 'number',
required: true,
visibility: 'user-only',
description: 'SFTP server port (default: 22)',
},
username: {
type: 'string',
required: true,
visibility: 'user-only',
description: 'SFTP username',
},
password: {
type: 'string',
required: false,
visibility: 'user-only',
description: 'Password for authentication (if not using private key)',
},
privateKey: {
type: 'string',
required: false,
visibility: 'user-only',
description: 'Private key for authentication (OpenSSH format)',
},
passphrase: {
type: 'string',
required: false,
visibility: 'user-only',
description: 'Passphrase for encrypted private key',
},
remotePath: {
type: 'string',
required: true,
visibility: 'user-or-llm',
description: 'Path to the file on the remote server',
},
encoding: {
type: 'string',
required: false,
visibility: 'user-only',
description: 'Output encoding: utf-8 for text, base64 for binary (default: utf-8)',
},
},
request: {
url: '/api/tools/sftp/download',
method: 'POST',
headers: () => ({
'Content-Type': 'application/json',
}),
body: (params) => ({
host: params.host,
port: Number(params.port) || 22,
username: params.username,
password: params.password,
privateKey: params.privateKey,
passphrase: params.passphrase,
remotePath: params.remotePath,
encoding: params.encoding || 'utf-8',
}),
},
transformResponse: async (response: Response) => {
const data = await response.json()
if (!response.ok) {
return {
success: false,
output: {
success: false,
},
error: data.error || 'SFTP download failed',
}
}
return {
success: true,
output: {
success: true,
fileName: data.fileName,
content: data.content,
size: data.size,
encoding: data.encoding,
message: data.message,
},
}
},
outputs: {
success: { type: 'boolean', description: 'Whether the download was successful' },
fileName: { type: 'string', description: 'Name of the downloaded file' },
content: { type: 'string', description: 'File content (text or base64 encoded)' },
size: { type: 'number', description: 'File size in bytes' },
encoding: { type: 'string', description: 'Content encoding (utf-8 or base64)' },
message: { type: 'string', description: 'Operation status message' },
},
}

View File

@@ -0,0 +1,18 @@
export { sftpDeleteTool } from './delete'
export { sftpDownloadTool } from './download'
export { sftpListTool } from './list'
export { sftpMkdirTool } from './mkdir'
export type {
SftpConnectionConfig,
SftpDeleteParams,
SftpDeleteResult,
SftpDownloadParams,
SftpDownloadResult,
SftpListParams,
SftpListResult,
SftpMkdirParams,
SftpMkdirResult,
SftpUploadParams,
SftpUploadResult,
} from './types'
export { sftpUploadTool } from './upload'

114
apps/sim/tools/sftp/list.ts Normal file
View File

@@ -0,0 +1,114 @@
import type { SftpListParams, SftpListResult } from '@/tools/sftp/types'
import type { ToolConfig } from '@/tools/types'
export const sftpListTool: ToolConfig<SftpListParams, SftpListResult> = {
id: 'sftp_list',
name: 'SFTP List Directory',
description: 'List files and directories on a remote SFTP server',
version: '1.0.0',
params: {
host: {
type: 'string',
required: true,
visibility: 'user-only',
description: 'SFTP server hostname or IP address',
},
port: {
type: 'number',
required: true,
visibility: 'user-only',
description: 'SFTP server port (default: 22)',
},
username: {
type: 'string',
required: true,
visibility: 'user-only',
description: 'SFTP username',
},
password: {
type: 'string',
required: false,
visibility: 'user-only',
description: 'Password for authentication (if not using private key)',
},
privateKey: {
type: 'string',
required: false,
visibility: 'user-only',
description: 'Private key for authentication (OpenSSH format)',
},
passphrase: {
type: 'string',
required: false,
visibility: 'user-only',
description: 'Passphrase for encrypted private key',
},
remotePath: {
type: 'string',
required: true,
visibility: 'user-or-llm',
description: 'Directory path on the remote server',
},
detailed: {
type: 'boolean',
required: false,
visibility: 'user-only',
description: 'Include detailed file information (size, permissions, modified date)',
},
},
request: {
url: '/api/tools/sftp/list',
method: 'POST',
headers: () => ({
'Content-Type': 'application/json',
}),
body: (params) => ({
host: params.host,
port: Number(params.port) || 22,
username: params.username,
password: params.password,
privateKey: params.privateKey,
passphrase: params.passphrase,
remotePath: params.remotePath,
detailed: params.detailed || false,
}),
},
transformResponse: async (response: Response) => {
const data = await response.json()
if (!response.ok) {
return {
success: false,
output: {
success: false,
},
error: data.error || 'SFTP list failed',
}
}
return {
success: true,
output: {
success: true,
path: data.path,
entries: data.entries,
count: data.count,
message: data.message,
},
}
},
outputs: {
success: { type: 'boolean', description: 'Whether the operation was successful' },
path: { type: 'string', description: 'Directory path that was listed' },
entries: {
type: 'json',
description: 'Array of directory entries with name, type, size, permissions, modifiedAt',
},
count: { type: 'number', description: 'Number of entries in the directory' },
message: { type: 'string', description: 'Operation status message' },
},
}

View File

@@ -0,0 +1,107 @@
import type { SftpMkdirParams, SftpMkdirResult } from '@/tools/sftp/types'
import type { ToolConfig } from '@/tools/types'
export const sftpMkdirTool: ToolConfig<SftpMkdirParams, SftpMkdirResult> = {
id: 'sftp_mkdir',
name: 'SFTP Create Directory',
description: 'Create a directory on a remote SFTP server',
version: '1.0.0',
params: {
host: {
type: 'string',
required: true,
visibility: 'user-only',
description: 'SFTP server hostname or IP address',
},
port: {
type: 'number',
required: true,
visibility: 'user-only',
description: 'SFTP server port (default: 22)',
},
username: {
type: 'string',
required: true,
visibility: 'user-only',
description: 'SFTP username',
},
password: {
type: 'string',
required: false,
visibility: 'user-only',
description: 'Password for authentication (if not using private key)',
},
privateKey: {
type: 'string',
required: false,
visibility: 'user-only',
description: 'Private key for authentication (OpenSSH format)',
},
passphrase: {
type: 'string',
required: false,
visibility: 'user-only',
description: 'Passphrase for encrypted private key',
},
remotePath: {
type: 'string',
required: true,
visibility: 'user-or-llm',
description: 'Path for the new directory',
},
recursive: {
type: 'boolean',
required: false,
visibility: 'user-only',
description: 'Create parent directories if they do not exist',
},
},
request: {
url: '/api/tools/sftp/mkdir',
method: 'POST',
headers: () => ({
'Content-Type': 'application/json',
}),
body: (params) => ({
host: params.host,
port: Number(params.port) || 22,
username: params.username,
password: params.password,
privateKey: params.privateKey,
passphrase: params.passphrase,
remotePath: params.remotePath,
recursive: params.recursive || false,
}),
},
transformResponse: async (response: Response) => {
const data = await response.json()
if (!response.ok) {
return {
success: false,
output: {
success: false,
},
error: data.error || 'SFTP mkdir failed',
}
}
return {
success: true,
output: {
success: true,
createdPath: data.createdPath,
message: data.message,
},
}
},
outputs: {
success: { type: 'boolean', description: 'Whether the directory was created successfully' },
createdPath: { type: 'string', description: 'Path of the created directory' },
message: { type: 'string', description: 'Operation status message' },
},
}

View File

@@ -0,0 +1,99 @@
import type { ToolResponse } from '@/tools/types'
export interface SftpConnectionConfig {
host: string
port: number
username: string
password?: string
privateKey?: string
passphrase?: string
}
// Upload file params
export interface SftpUploadParams extends SftpConnectionConfig {
remotePath: string
files?: any[] // UserFile array from file-upload component
fileContent?: string // Direct content for text files
fileName?: string // File name when using direct content
overwrite?: boolean
permissions?: string
}
export interface SftpUploadResult extends ToolResponse {
output: {
success: boolean
uploadedFiles?: Array<{
name: string
remotePath: string
size: number
}>
message?: string
}
}
// Download file params
export interface SftpDownloadParams extends SftpConnectionConfig {
remotePath: string
encoding?: 'utf-8' | 'base64'
}
export interface SftpDownloadResult extends ToolResponse {
output: {
success: boolean
fileName?: string
content?: string
size?: number
encoding?: string
message?: string
}
}
// List directory params
export interface SftpListParams extends SftpConnectionConfig {
remotePath: string
detailed?: boolean
}
export interface SftpListResult extends ToolResponse {
output: {
success: boolean
path?: string
entries?: Array<{
name: string
type: 'file' | 'directory' | 'symlink' | 'other'
size?: number
permissions?: string
modifiedAt?: string
}>
count?: number
message?: string
}
}
// Delete file params
export interface SftpDeleteParams extends SftpConnectionConfig {
remotePath: string
recursive?: boolean
}
export interface SftpDeleteResult extends ToolResponse {
output: {
success: boolean
deletedPath?: string
message?: string
}
}
// Mkdir params
export interface SftpMkdirParams extends SftpConnectionConfig {
remotePath: string
recursive?: boolean
}
export interface SftpMkdirResult extends ToolResponse {
output: {
success: boolean
createdPath?: string
message?: string
}
}

View File

@@ -0,0 +1,138 @@
import type { SftpUploadParams, SftpUploadResult } from '@/tools/sftp/types'
import type { ToolConfig } from '@/tools/types'
export const sftpUploadTool: ToolConfig<SftpUploadParams, SftpUploadResult> = {
id: 'sftp_upload',
name: 'SFTP Upload',
description: 'Upload files to a remote SFTP server',
version: '1.0.0',
params: {
host: {
type: 'string',
required: true,
visibility: 'user-only',
description: 'SFTP server hostname or IP address',
},
port: {
type: 'number',
required: true,
visibility: 'user-only',
description: 'SFTP server port (default: 22)',
},
username: {
type: 'string',
required: true,
visibility: 'user-only',
description: 'SFTP username',
},
password: {
type: 'string',
required: false,
visibility: 'user-only',
description: 'Password for authentication (if not using private key)',
},
privateKey: {
type: 'string',
required: false,
visibility: 'user-only',
description: 'Private key for authentication (OpenSSH format)',
},
passphrase: {
type: 'string',
required: false,
visibility: 'user-only',
description: 'Passphrase for encrypted private key',
},
remotePath: {
type: 'string',
required: true,
visibility: 'user-or-llm',
description: 'Destination directory on the remote server',
},
files: {
type: 'file[]',
required: false,
visibility: 'user-only',
description: 'Files to upload',
},
fileContent: {
type: 'string',
required: false,
visibility: 'user-or-llm',
description: 'Direct file content to upload (for text files)',
},
fileName: {
type: 'string',
required: false,
visibility: 'user-or-llm',
description: 'File name when using direct content',
},
overwrite: {
type: 'boolean',
required: false,
visibility: 'user-only',
description: 'Whether to overwrite existing files (default: true)',
},
permissions: {
type: 'string',
required: false,
visibility: 'user-only',
description: 'File permissions (e.g., 0644)',
},
},
request: {
url: '/api/tools/sftp/upload',
method: 'POST',
headers: () => ({
'Content-Type': 'application/json',
}),
body: (params) => ({
host: params.host,
port: Number(params.port) || 22,
username: params.username,
password: params.password,
privateKey: params.privateKey,
passphrase: params.passphrase,
remotePath: params.remotePath,
files: params.files,
fileContent: params.fileContent,
fileName: params.fileName,
overwrite: params.overwrite !== false,
permissions: params.permissions,
}),
},
transformResponse: async (response: Response) => {
const data = await response.json()
if (!response.ok) {
return {
success: false,
output: {
success: false,
},
error: data.error || 'SFTP upload failed',
}
}
return {
success: true,
output: {
success: true,
uploadedFiles: data.uploadedFiles,
message: data.message,
},
}
},
outputs: {
success: { type: 'boolean', description: 'Whether the upload was successful' },
uploadedFiles: {
type: 'json',
description: 'Array of uploaded file details (name, remotePath, size)',
},
message: { type: 'string', description: 'Operation status message' },
},
}