mirror of
https://github.com/simstudioai/sim.git
synced 2026-03-15 03:00:33 -04:00
Compare commits
15 Commits
feat/migra
...
waleedlati
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
448548ec31 | ||
|
|
5782b6830e | ||
|
|
c4cb2aaf06 | ||
|
|
7b7ab8d2c2 | ||
|
|
3d0b07b555 | ||
|
|
281f5bb213 | ||
|
|
8622ea4349 | ||
|
|
6774ad132b | ||
|
|
94679a6e55 | ||
|
|
0f293e8584 | ||
|
|
5dc31b5cf9 | ||
|
|
24e1bfdf09 | ||
|
|
cf67966e15 | ||
|
|
2a8aae68c5 | ||
|
|
6802245a8a |
@@ -32,3 +32,7 @@ API_ENCRYPTION_KEY=your_api_encryption_key # Use `openssl rand -hex 32` to gener
|
||||
# Admin API (Optional - for self-hosted GitOps)
|
||||
# ADMIN_API_KEY= # Use `openssl rand -hex 32` to generate. Enables admin API for workflow export/import.
|
||||
# Usage: curl -H "x-admin-key: your_key" https://your-instance/api/v1/admin/workspaces
|
||||
|
||||
# Execute Command (Optional - self-hosted only)
|
||||
# EXECUTE_COMMAND_ENABLED=true # Enable shell command execution in workflows
|
||||
# NEXT_PUBLIC_EXECUTE_COMMAND_ENABLED=true # Show Execute Command block in the UI
|
||||
|
||||
@@ -366,7 +366,7 @@ function resolveWorkflowVariables(
|
||||
const variableName = match[1].trim()
|
||||
|
||||
const foundVariable = Object.entries(workflowVariables).find(
|
||||
([_, variable]) => normalizeName(variable.name || '') === variableName
|
||||
([_, variable]) => normalizeName(variable.name || '') === normalizeName(variableName)
|
||||
)
|
||||
|
||||
if (!foundVariable) {
|
||||
|
||||
424
apps/sim/app/api/tools/execute-command/run/route.ts
Normal file
424
apps/sim/app/api/tools/execute-command/run/route.ts
Normal file
@@ -0,0 +1,424 @@
|
||||
import { exec } from 'child_process'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { checkInternalAuth } from '@/lib/auth/hybrid'
|
||||
import { isExecuteCommandEnabled } from '@/lib/core/config/feature-flags'
|
||||
import { generateRequestId } from '@/lib/core/utils/request'
|
||||
import { DEFAULT_EXECUTION_TIMEOUT_MS } from '@/lib/execution/constants'
|
||||
import { normalizeName, REFERENCE, SPECIAL_REFERENCE_PREFIXES } from '@/executor/constants'
|
||||
import { type OutputSchema, resolveBlockReference } from '@/executor/utils/block-reference'
|
||||
import {
|
||||
createEnvVarPattern,
|
||||
createWorkflowVariablePattern,
|
||||
} from '@/executor/utils/reference-validation'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
export const runtime = 'nodejs'
|
||||
export const MAX_DURATION = 210
|
||||
|
||||
const logger = createLogger('ExecuteCommandAPI')
|
||||
|
||||
const MAX_BUFFER = 10 * 1024 * 1024 // 10MB
|
||||
|
||||
const SAFE_ENV_KEYS = ['PATH', 'HOME', 'SHELL', 'USER', 'LOGNAME', 'LANG', 'TERM', 'TZ'] as const
|
||||
|
||||
/**
|
||||
* Returns a minimal base environment for child processes.
|
||||
* Only includes POSIX essentials — never server secrets like DATABASE_URL, AUTH_SECRET, etc.
|
||||
*/
|
||||
function getSafeBaseEnv(): NodeJS.ProcessEnv {
|
||||
const env: NodeJS.ProcessEnv = { NODE_ENV: process.env.NODE_ENV }
|
||||
for (const key of SAFE_ENV_KEYS) {
|
||||
if (process.env[key]) {
|
||||
env[key] = process.env[key]
|
||||
}
|
||||
}
|
||||
return env
|
||||
}
|
||||
|
||||
const BLOCKED_ENV_KEYS = new Set([
|
||||
...SAFE_ENV_KEYS,
|
||||
'NODE_ENV',
|
||||
'NODE_OPTIONS',
|
||||
'LD_PRELOAD',
|
||||
'LD_LIBRARY_PATH',
|
||||
'LD_AUDIT',
|
||||
'DYLD_INSERT_LIBRARIES',
|
||||
'DYLD_LIBRARY_PATH',
|
||||
'DYLD_FRAMEWORK_PATH',
|
||||
'BASH_ENV',
|
||||
'ENV',
|
||||
'GLIBC_TUNABLES',
|
||||
'NODE_PATH',
|
||||
'PYTHONPATH',
|
||||
'PERL5LIB',
|
||||
'PERL5OPT',
|
||||
'RUBYLIB',
|
||||
'JAVA_TOOL_OPTIONS',
|
||||
])
|
||||
|
||||
/**
|
||||
* Filters user-supplied env vars to prevent overriding safe base env
|
||||
* and injecting process-influencing variables.
|
||||
*/
|
||||
function filterUserEnv(env?: Record<string, string>): Record<string, string> {
|
||||
if (!env) return {}
|
||||
const filtered: Record<string, string> = {}
|
||||
for (const [key, value] of Object.entries(env)) {
|
||||
if (!BLOCKED_ENV_KEYS.has(key)) {
|
||||
filtered[key] = value
|
||||
}
|
||||
}
|
||||
return filtered
|
||||
}
|
||||
|
||||
interface Replacement {
|
||||
index: number
|
||||
length: number
|
||||
value: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Collects workflow variable (<variable.name>) replacements from the original command string
|
||||
*/
|
||||
function collectWorkflowVariableReplacements(
|
||||
command: string,
|
||||
workflowVariables: Record<string, any>
|
||||
): Replacement[] {
|
||||
const regex = createWorkflowVariablePattern()
|
||||
const replacements: Replacement[] = []
|
||||
let match: RegExpExecArray | null
|
||||
|
||||
while ((match = regex.exec(command)) !== null) {
|
||||
const variableName = match[1].trim()
|
||||
const foundVariable = Object.entries(workflowVariables).find(
|
||||
([_, variable]) => normalizeName(variable.name || '') === normalizeName(variableName)
|
||||
)
|
||||
|
||||
if (!foundVariable) {
|
||||
const availableVars = Object.values(workflowVariables)
|
||||
.map((v: any) => v.name)
|
||||
.filter(Boolean)
|
||||
throw new Error(
|
||||
`Variable "${variableName}" doesn't exist.` +
|
||||
(availableVars.length > 0 ? ` Available: ${availableVars.join(', ')}` : '')
|
||||
)
|
||||
}
|
||||
|
||||
const variable = foundVariable[1]
|
||||
let value = variable.value
|
||||
|
||||
if (typeof value === 'object' && value !== null) {
|
||||
value = JSON.stringify(value)
|
||||
} else {
|
||||
value = String(value ?? '')
|
||||
}
|
||||
|
||||
replacements.push({ index: match.index, length: match[0].length, value })
|
||||
}
|
||||
|
||||
return replacements
|
||||
}
|
||||
|
||||
/**
|
||||
* Collects environment variable ({{ENV_VAR}}) replacements from the original command string
|
||||
*/
|
||||
function collectEnvVarReplacements(
|
||||
command: string,
|
||||
envVars: Record<string, string>
|
||||
): Replacement[] {
|
||||
const regex = createEnvVarPattern()
|
||||
const replacements: Replacement[] = []
|
||||
let match: RegExpExecArray | null
|
||||
|
||||
while ((match = regex.exec(command)) !== null) {
|
||||
const varName = match[1].trim()
|
||||
if (!(varName in envVars)) {
|
||||
continue
|
||||
}
|
||||
replacements.push({ index: match.index, length: match[0].length, value: envVars[varName] })
|
||||
}
|
||||
|
||||
return replacements
|
||||
}
|
||||
|
||||
/**
|
||||
* Collects block reference tag (<blockName.field>) replacements from the original command string
|
||||
*/
|
||||
function collectTagReplacements(
|
||||
command: string,
|
||||
blockData: Record<string, unknown>,
|
||||
blockNameMapping: Record<string, string>,
|
||||
blockOutputSchemas: Record<string, OutputSchema>
|
||||
): Replacement[] {
|
||||
const tagPattern = new RegExp(
|
||||
`${REFERENCE.START}(\\S[^${REFERENCE.START}${REFERENCE.END}]*)${REFERENCE.END}`,
|
||||
'g'
|
||||
)
|
||||
|
||||
const replacements: Replacement[] = []
|
||||
let match: RegExpExecArray | null
|
||||
|
||||
while ((match = tagPattern.exec(command)) !== null) {
|
||||
const tagName = match[1].trim()
|
||||
const pathParts = tagName.split(REFERENCE.PATH_DELIMITER)
|
||||
const blockName = pathParts[0]
|
||||
const fieldPath = pathParts.slice(1)
|
||||
|
||||
const result = resolveBlockReference(blockName, fieldPath, {
|
||||
blockNameMapping,
|
||||
blockData,
|
||||
blockOutputSchemas,
|
||||
})
|
||||
|
||||
if (!result) {
|
||||
const isSpecialPrefix = SPECIAL_REFERENCE_PREFIXES.some((prefix) => blockName === prefix)
|
||||
if (!isSpecialPrefix) {
|
||||
throw new Error(
|
||||
`Block reference "<${tagName}>" could not be resolved. Check that the block name and field path are correct.`
|
||||
)
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
let stringValue: string
|
||||
if (result.value === undefined || result.value === null) {
|
||||
stringValue = ''
|
||||
} else if (typeof result.value === 'object') {
|
||||
stringValue = JSON.stringify(result.value)
|
||||
} else {
|
||||
stringValue = String(result.value)
|
||||
}
|
||||
|
||||
replacements.push({ index: match.index, length: match[0].length, value: stringValue })
|
||||
}
|
||||
|
||||
return replacements
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves all variable references in a command string in a single pass.
|
||||
* All three patterns are matched against the ORIGINAL command to prevent
|
||||
* cascading resolution (e.g. a workflow variable value containing {{ENV_VAR}}
|
||||
* would NOT be further resolved).
|
||||
*/
|
||||
function resolveCommandVariables(
|
||||
command: string,
|
||||
envVars: Record<string, string>,
|
||||
blockData: Record<string, unknown>,
|
||||
blockNameMapping: Record<string, string>,
|
||||
blockOutputSchemas: Record<string, OutputSchema>,
|
||||
workflowVariables: Record<string, unknown>
|
||||
): string {
|
||||
const allReplacements = [
|
||||
...collectWorkflowVariableReplacements(command, workflowVariables),
|
||||
...collectEnvVarReplacements(command, envVars),
|
||||
...collectTagReplacements(command, blockData, blockNameMapping, blockOutputSchemas),
|
||||
]
|
||||
|
||||
allReplacements.sort((a, b) => a.index - b.index)
|
||||
|
||||
for (let i = 1; i < allReplacements.length; i++) {
|
||||
const prev = allReplacements[i - 1]
|
||||
const curr = allReplacements[i]
|
||||
if (curr.index < prev.index + prev.length) {
|
||||
throw new Error(
|
||||
`Overlapping variable references detected at positions ${prev.index} and ${curr.index}`
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
let resolved = command
|
||||
for (let i = allReplacements.length - 1; i >= 0; i--) {
|
||||
const { index, length, value } = allReplacements[i]
|
||||
resolved = resolved.slice(0, index) + value + resolved.slice(index + length)
|
||||
}
|
||||
|
||||
return resolved
|
||||
}
|
||||
|
||||
interface CommandResult {
|
||||
stdout: string
|
||||
stderr: string
|
||||
exitCode: number
|
||||
timedOut: boolean
|
||||
maxBufferExceeded: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute a shell command and return stdout, stderr, exitCode.
|
||||
* Distinguishes between a process that exited with non-zero (normal) and one that was killed (timeout).
|
||||
*/
|
||||
function executeCommand(
|
||||
command: string,
|
||||
options: { timeout: number; cwd?: string; env?: Record<string, string> }
|
||||
): Promise<CommandResult> {
|
||||
return new Promise((resolve) => {
|
||||
const childProcess = exec(
|
||||
command,
|
||||
{
|
||||
timeout: options.timeout,
|
||||
cwd: options.cwd || undefined,
|
||||
maxBuffer: MAX_BUFFER,
|
||||
env: { ...getSafeBaseEnv(), ...filterUserEnv(options.env) },
|
||||
},
|
||||
(error, stdout, stderr) => {
|
||||
if (error) {
|
||||
const killed = error.killed ?? false
|
||||
const isMaxBuffer = /maxBuffer/i.test(error.message ?? '')
|
||||
const exitCode = typeof error.code === 'number' ? error.code : 1
|
||||
resolve({
|
||||
stdout: stdout.trimEnd(),
|
||||
stderr: stderr.trimEnd(),
|
||||
exitCode,
|
||||
timedOut: killed && !isMaxBuffer,
|
||||
maxBufferExceeded: isMaxBuffer,
|
||||
})
|
||||
return
|
||||
}
|
||||
resolve({
|
||||
stdout: stdout.trimEnd(),
|
||||
stderr: stderr.trimEnd(),
|
||||
exitCode: 0,
|
||||
timedOut: false,
|
||||
maxBufferExceeded: false,
|
||||
})
|
||||
}
|
||||
)
|
||||
|
||||
childProcess.on('error', (err) => {
|
||||
resolve({
|
||||
stdout: '',
|
||||
stderr: err.message,
|
||||
exitCode: 1,
|
||||
timedOut: false,
|
||||
maxBufferExceeded: false,
|
||||
})
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
const requestId = generateRequestId()
|
||||
|
||||
try {
|
||||
if (!isExecuteCommandEnabled) {
|
||||
logger.warn(`[${requestId}] Execute Command is disabled`)
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error:
|
||||
'Execute Command is not enabled. Set EXECUTE_COMMAND_ENABLED=true in your environment to use this feature. Only available for self-hosted deployments.',
|
||||
},
|
||||
{ status: 403 }
|
||||
)
|
||||
}
|
||||
|
||||
const auth = await checkInternalAuth(req)
|
||||
if (!auth.success || !auth.userId) {
|
||||
logger.warn(`[${requestId}] Unauthorized execute command attempt`)
|
||||
return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const body = await req.json()
|
||||
|
||||
const {
|
||||
command,
|
||||
workingDirectory,
|
||||
envVars = {},
|
||||
blockData = {},
|
||||
blockNameMapping = {},
|
||||
blockOutputSchemas = {},
|
||||
workflowVariables = {},
|
||||
workflowId,
|
||||
} = body
|
||||
|
||||
const MAX_ALLOWED_TIMEOUT_MS = (MAX_DURATION - 10) * 1000
|
||||
const parsedTimeout = Number(body.timeout)
|
||||
const timeout = Math.min(
|
||||
parsedTimeout > 0 ? parsedTimeout : DEFAULT_EXECUTION_TIMEOUT_MS,
|
||||
MAX_ALLOWED_TIMEOUT_MS
|
||||
)
|
||||
|
||||
if (!command || typeof command !== 'string') {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: 'Command is required and must be a string' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
logger.info(`[${requestId}] Execute command request`, {
|
||||
commandLength: command.length,
|
||||
timeout,
|
||||
workingDirectory: workingDirectory || '(default)',
|
||||
workflowId,
|
||||
})
|
||||
|
||||
const resolvedCommand = resolveCommandVariables(
|
||||
command,
|
||||
envVars,
|
||||
blockData,
|
||||
blockNameMapping,
|
||||
blockOutputSchemas,
|
||||
workflowVariables
|
||||
)
|
||||
|
||||
const result = await executeCommand(resolvedCommand, {
|
||||
timeout,
|
||||
cwd: workingDirectory,
|
||||
env: envVars,
|
||||
})
|
||||
|
||||
logger.info(`[${requestId}] Command completed`, {
|
||||
exitCode: result.exitCode,
|
||||
timedOut: result.timedOut,
|
||||
stdoutLength: result.stdout.length,
|
||||
stderrLength: result.stderr.length,
|
||||
workflowId,
|
||||
})
|
||||
|
||||
if (result.timedOut) {
|
||||
return NextResponse.json({
|
||||
success: false,
|
||||
output: {
|
||||
stdout: result.stdout,
|
||||
stderr: result.stderr,
|
||||
exitCode: result.exitCode,
|
||||
},
|
||||
error: `Command timed out after ${timeout}ms`,
|
||||
})
|
||||
}
|
||||
|
||||
if (result.maxBufferExceeded) {
|
||||
return NextResponse.json({
|
||||
success: false,
|
||||
output: {
|
||||
stdout: result.stdout,
|
||||
stderr: result.stderr,
|
||||
exitCode: result.exitCode,
|
||||
},
|
||||
error: `Command output exceeded maximum buffer size of ${MAX_BUFFER / 1024 / 1024}MB`,
|
||||
})
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
output: {
|
||||
stdout: result.stdout,
|
||||
stderr: result.stderr,
|
||||
exitCode: result.exitCode,
|
||||
},
|
||||
})
|
||||
} catch (error: unknown) {
|
||||
const message = error instanceof Error ? error.message : 'Unknown error'
|
||||
logger.error(`[${requestId}] Execute command failed`, { error: message })
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
output: { stdout: '', stderr: message, exitCode: 1 },
|
||||
error: message,
|
||||
},
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
88
apps/sim/blocks/blocks/execute-command.ts
Normal file
88
apps/sim/blocks/blocks/execute-command.ts
Normal file
@@ -0,0 +1,88 @@
|
||||
import { TerminalIcon } from '@/components/icons'
|
||||
import { isTruthy } from '@/lib/core/config/env'
|
||||
import { DEFAULT_EXECUTION_TIMEOUT_MS } from '@/lib/execution/constants'
|
||||
import type { BlockConfig } from '@/blocks/types'
|
||||
import type { ExecuteCommandOutput } from '@/tools/execute-command/types'
|
||||
|
||||
export const ExecuteCommandBlock: BlockConfig<ExecuteCommandOutput> = {
|
||||
type: 'execute_command',
|
||||
name: 'Execute Command',
|
||||
description: 'Run shell commands',
|
||||
hideFromToolbar: !isTruthy(process.env.NEXT_PUBLIC_EXECUTE_COMMAND_ENABLED),
|
||||
longDescription:
|
||||
'Execute shell commands on the host machine. Only available for self-hosted deployments with EXECUTE_COMMAND_ENABLED=true. Commands run in the default shell of the host OS.',
|
||||
bestPractices: `
|
||||
- Commands execute in the default shell of the host machine (bash, zsh, cmd, PowerShell).
|
||||
- Chain multiple commands with && to run them sequentially.
|
||||
- Use <blockName.output> syntax to reference outputs from other blocks.
|
||||
- Use {{ENV_VAR}} syntax to reference environment variables.
|
||||
- The working directory defaults to the server process directory if not specified. Variable references are not supported in the Working Directory field — use a literal path.
|
||||
- A non-zero exit code is returned as data (exitCode > 0), not treated as a workflow error. Use a Condition block to branch on exitCode if needed.
|
||||
- Variable values from other blocks are interpolated directly into the command string. Avoid passing untrusted user input as block references to prevent shell injection.
|
||||
`,
|
||||
docsLink: 'https://docs.sim.ai/blocks/execute-command',
|
||||
category: 'blocks',
|
||||
bgColor: '#1E1E1E',
|
||||
icon: TerminalIcon,
|
||||
subBlocks: [
|
||||
{
|
||||
id: 'command',
|
||||
title: 'Command',
|
||||
type: 'long-input',
|
||||
required: true,
|
||||
placeholder: 'echo "Hello, World!"',
|
||||
wandConfig: {
|
||||
enabled: true,
|
||||
prompt: `You are an expert shell scripting assistant.
|
||||
Generate ONLY the raw shell command(s) based on the user's request. Never wrap in markdown formatting.
|
||||
The command runs in the default shell of the host OS (bash, zsh, cmd, or PowerShell).
|
||||
|
||||
- Reference outputs from previous blocks using angle bracket syntax: <blockName.output>
|
||||
- Reference environment variables using double curly brace syntax: {{ENV_VAR_NAME}}
|
||||
- Chain multiple commands with && to run them sequentially.
|
||||
- Use pipes (|) to chain command output.
|
||||
|
||||
Current command context: {context}
|
||||
|
||||
IMPORTANT FORMATTING RULES:
|
||||
1. Output ONLY the shell command(s). No explanations, no markdown, no comments.
|
||||
2. Use <blockName.field> to reference block outputs. Do NOT wrap in quotes.
|
||||
3. Use {{VAR_NAME}} to reference environment variables. Do NOT wrap in quotes.
|
||||
4. Write portable commands when possible (prefer POSIX-compatible syntax).
|
||||
5. For multi-step operations, chain with && or use subshells.`,
|
||||
placeholder: 'Describe the command you want to run...',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'workingDirectory',
|
||||
title: 'Working Directory',
|
||||
type: 'short-input',
|
||||
required: false,
|
||||
placeholder: '/path/to/directory',
|
||||
},
|
||||
{
|
||||
id: 'timeout',
|
||||
title: 'Timeout (ms)',
|
||||
type: 'short-input',
|
||||
required: false,
|
||||
placeholder: String(DEFAULT_EXECUTION_TIMEOUT_MS),
|
||||
},
|
||||
],
|
||||
tools: {
|
||||
access: ['execute_command_run'],
|
||||
},
|
||||
inputs: {
|
||||
command: { type: 'string', description: 'Shell command to execute' },
|
||||
workingDirectory: { type: 'string', description: 'Working directory for the command' },
|
||||
timeout: { type: 'number', description: 'Execution timeout in milliseconds' },
|
||||
},
|
||||
outputs: {
|
||||
stdout: { type: 'string', description: 'Standard output from the command' },
|
||||
stderr: { type: 'string', description: 'Standard error output from the command' },
|
||||
exitCode: { type: 'number', description: 'Exit code of the command (0 = success)' },
|
||||
error: {
|
||||
type: 'string',
|
||||
description: 'Error message if the command timed out or exceeded buffer limits',
|
||||
},
|
||||
},
|
||||
}
|
||||
@@ -39,6 +39,7 @@ import { ElevenLabsBlock } from '@/blocks/blocks/elevenlabs'
|
||||
import { EnrichBlock } from '@/blocks/blocks/enrich'
|
||||
import { EvaluatorBlock } from '@/blocks/blocks/evaluator'
|
||||
import { ExaBlock } from '@/blocks/blocks/exa'
|
||||
import { ExecuteCommandBlock } from '@/blocks/blocks/execute-command'
|
||||
import { FileBlock, FileV2Block, FileV3Block } from '@/blocks/blocks/file'
|
||||
import { FirecrawlBlock } from '@/blocks/blocks/firecrawl'
|
||||
import { FirefliesBlock, FirefliesV2Block } from '@/blocks/blocks/fireflies'
|
||||
@@ -235,6 +236,7 @@ export const registry: Record<string, BlockConfig> = {
|
||||
elevenlabs: ElevenLabsBlock,
|
||||
enrich: EnrichBlock,
|
||||
evaluator: EvaluatorBlock,
|
||||
execute_command: ExecuteCommandBlock,
|
||||
exa: ExaBlock,
|
||||
file: FileBlock,
|
||||
file_v2: FileV2Block,
|
||||
|
||||
@@ -367,6 +367,34 @@ export function CodeIcon(props: SVGProps<SVGSVGElement>) {
|
||||
)
|
||||
}
|
||||
|
||||
export function TerminalIcon(props: SVGProps<SVGSVGElement>) {
|
||||
return (
|
||||
<svg
|
||||
{...props}
|
||||
width='24'
|
||||
height='24'
|
||||
viewBox='0 0 24 24'
|
||||
fill='none'
|
||||
xmlns='http://www.w3.org/2000/svg'
|
||||
>
|
||||
<path
|
||||
d='M4 17L10 11L4 5'
|
||||
stroke='currentColor'
|
||||
strokeWidth='2'
|
||||
strokeLinecap='round'
|
||||
strokeLinejoin='round'
|
||||
/>
|
||||
<path
|
||||
d='M12 19H20'
|
||||
stroke='currentColor'
|
||||
strokeWidth='2'
|
||||
strokeLinecap='round'
|
||||
strokeLinejoin='round'
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
export function ChartBarIcon(props: SVGProps<SVGSVGElement>) {
|
||||
return (
|
||||
<svg
|
||||
|
||||
@@ -24,6 +24,7 @@ export enum BlockType {
|
||||
TRIGGER = 'trigger',
|
||||
|
||||
FUNCTION = 'function',
|
||||
EXECUTE_COMMAND = 'execute_command',
|
||||
AGENT = 'agent',
|
||||
API = 'api',
|
||||
EVALUATOR = 'evaluator',
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
import { DEFAULT_EXECUTION_TIMEOUT_MS } from '@/lib/execution/constants'
|
||||
import { BlockType } from '@/executor/constants'
|
||||
import type { BlockHandler, ExecutionContext } from '@/executor/types'
|
||||
import { collectBlockData } from '@/executor/utils/block-data'
|
||||
import type { SerializedBlock } from '@/serializer/types'
|
||||
import { executeTool } from '@/tools'
|
||||
|
||||
/**
|
||||
* Handler for Execute Command blocks that run shell commands on the host machine.
|
||||
*/
|
||||
export class ExecuteCommandBlockHandler implements BlockHandler {
|
||||
canHandle(block: SerializedBlock): boolean {
|
||||
return block.metadata?.id === BlockType.EXECUTE_COMMAND
|
||||
}
|
||||
|
||||
async execute(
|
||||
ctx: ExecutionContext,
|
||||
block: SerializedBlock,
|
||||
inputs: Record<string, any>
|
||||
): Promise<any> {
|
||||
const { blockData, blockNameMapping, blockOutputSchemas } = collectBlockData(ctx)
|
||||
|
||||
const result = await executeTool(
|
||||
'execute_command_run',
|
||||
{
|
||||
command: inputs.command,
|
||||
timeout: inputs.timeout || DEFAULT_EXECUTION_TIMEOUT_MS,
|
||||
workingDirectory: inputs.workingDirectory,
|
||||
envVars: ctx.environmentVariables || {},
|
||||
workflowVariables: ctx.workflowVariables || {},
|
||||
blockData,
|
||||
blockNameMapping,
|
||||
blockOutputSchemas,
|
||||
_context: {
|
||||
workflowId: ctx.workflowId,
|
||||
workspaceId: ctx.workspaceId,
|
||||
userId: ctx.userId,
|
||||
isDeployedContext: ctx.isDeployedContext,
|
||||
enforceCredentialAccess: ctx.enforceCredentialAccess,
|
||||
},
|
||||
},
|
||||
false,
|
||||
ctx
|
||||
)
|
||||
|
||||
if (!result.success) {
|
||||
return {
|
||||
...(result.output ?? { stdout: '', stderr: '', exitCode: 1 }),
|
||||
error: result.error || 'Command execution failed',
|
||||
}
|
||||
}
|
||||
|
||||
return { ...result.output, error: null }
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import { AgentBlockHandler } from '@/executor/handlers/agent/agent-handler'
|
||||
import { ApiBlockHandler } from '@/executor/handlers/api/api-handler'
|
||||
import { ConditionBlockHandler } from '@/executor/handlers/condition/condition-handler'
|
||||
import { EvaluatorBlockHandler } from '@/executor/handlers/evaluator/evaluator-handler'
|
||||
import { ExecuteCommandBlockHandler } from '@/executor/handlers/execute-command/execute-command-handler'
|
||||
import { FunctionBlockHandler } from '@/executor/handlers/function/function-handler'
|
||||
import { GenericBlockHandler } from '@/executor/handlers/generic/generic-handler'
|
||||
import { HumanInTheLoopBlockHandler } from '@/executor/handlers/human-in-the-loop/human-in-the-loop-handler'
|
||||
@@ -17,6 +18,7 @@ export {
|
||||
ApiBlockHandler,
|
||||
ConditionBlockHandler,
|
||||
EvaluatorBlockHandler,
|
||||
ExecuteCommandBlockHandler,
|
||||
FunctionBlockHandler,
|
||||
GenericBlockHandler,
|
||||
ResponseBlockHandler,
|
||||
|
||||
@@ -9,6 +9,7 @@ import { AgentBlockHandler } from '@/executor/handlers/agent/agent-handler'
|
||||
import { ApiBlockHandler } from '@/executor/handlers/api/api-handler'
|
||||
import { ConditionBlockHandler } from '@/executor/handlers/condition/condition-handler'
|
||||
import { EvaluatorBlockHandler } from '@/executor/handlers/evaluator/evaluator-handler'
|
||||
import { ExecuteCommandBlockHandler } from '@/executor/handlers/execute-command/execute-command-handler'
|
||||
import { FunctionBlockHandler } from '@/executor/handlers/function/function-handler'
|
||||
import { GenericBlockHandler } from '@/executor/handlers/generic/generic-handler'
|
||||
import { HumanInTheLoopBlockHandler } from '@/executor/handlers/human-in-the-loop/human-in-the-loop-handler'
|
||||
@@ -30,6 +31,7 @@ export function createBlockHandlers(): BlockHandler[] {
|
||||
return [
|
||||
new TriggerBlockHandler(),
|
||||
new FunctionBlockHandler(),
|
||||
new ExecuteCommandBlockHandler(),
|
||||
new ApiBlockHandler(),
|
||||
new ConditionBlockHandler(),
|
||||
new RouterBlockHandler(),
|
||||
|
||||
@@ -288,6 +288,9 @@ export const env = createEnv({
|
||||
E2B_ENABLED: z.string().optional(), // Enable E2B remote code execution
|
||||
E2B_API_KEY: z.string().optional(), // E2B API key for sandbox creation
|
||||
|
||||
// Execute Command - for self-hosted deployments
|
||||
EXECUTE_COMMAND_ENABLED: z.string().optional(), // Enable shell command execution (self-hosted only)
|
||||
|
||||
// Credential Sets (Email Polling) - for self-hosted deployments
|
||||
CREDENTIAL_SETS_ENABLED: z.boolean().optional(), // Enable credential sets on self-hosted (bypasses plan requirements)
|
||||
|
||||
@@ -366,6 +369,7 @@ export const env = createEnv({
|
||||
NEXT_PUBLIC_SUPPORT_EMAIL: z.string().email().optional(), // Custom support email
|
||||
|
||||
NEXT_PUBLIC_E2B_ENABLED: z.string().optional(),
|
||||
NEXT_PUBLIC_EXECUTE_COMMAND_ENABLED: z.string().optional(),
|
||||
NEXT_PUBLIC_COPILOT_TRAINING_ENABLED: z.string().optional(),
|
||||
NEXT_PUBLIC_ENABLE_PLAYGROUND: z.string().optional(), // Enable component playground at /playground
|
||||
NEXT_PUBLIC_DOCUMENTATION_URL: z.string().url().optional(), // Custom documentation URL
|
||||
@@ -420,6 +424,7 @@ export const env = createEnv({
|
||||
NEXT_PUBLIC_DISABLE_PUBLIC_API: process.env.NEXT_PUBLIC_DISABLE_PUBLIC_API,
|
||||
NEXT_PUBLIC_EMAIL_PASSWORD_SIGNUP_ENABLED: process.env.NEXT_PUBLIC_EMAIL_PASSWORD_SIGNUP_ENABLED,
|
||||
NEXT_PUBLIC_E2B_ENABLED: process.env.NEXT_PUBLIC_E2B_ENABLED,
|
||||
NEXT_PUBLIC_EXECUTE_COMMAND_ENABLED: process.env.NEXT_PUBLIC_EXECUTE_COMMAND_ENABLED,
|
||||
NEXT_PUBLIC_COPILOT_TRAINING_ENABLED: process.env.NEXT_PUBLIC_COPILOT_TRAINING_ENABLED,
|
||||
NEXT_PUBLIC_ENABLE_PLAYGROUND: process.env.NEXT_PUBLIC_ENABLE_PLAYGROUND,
|
||||
NEXT_PUBLIC_POSTHOG_ENABLED: process.env.NEXT_PUBLIC_POSTHOG_ENABLED,
|
||||
|
||||
@@ -105,6 +105,31 @@ export const isOrganizationsEnabled =
|
||||
*/
|
||||
export const isE2bEnabled = isTruthy(env.E2B_ENABLED)
|
||||
|
||||
/**
|
||||
* Is Execute Command enabled for running shell commands on the host machine.
|
||||
* Only available for self-hosted deployments. Blocked on hosted environments.
|
||||
*/
|
||||
export const isExecuteCommandEnabled = isTruthy(env.EXECUTE_COMMAND_ENABLED) && !isHosted
|
||||
|
||||
if (isTruthy(env.EXECUTE_COMMAND_ENABLED)) {
|
||||
import('@sim/logger')
|
||||
.then(({ createLogger }) => {
|
||||
const logger = createLogger('FeatureFlags')
|
||||
if (isHosted) {
|
||||
logger.error(
|
||||
'EXECUTE_COMMAND_ENABLED is set but ignored on hosted environment. Shell command execution is not available on hosted instances for security.'
|
||||
)
|
||||
} else {
|
||||
logger.warn(
|
||||
'EXECUTE_COMMAND_ENABLED is enabled. Workflows can execute arbitrary shell commands on the host machine. Only use this in trusted environments.'
|
||||
)
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
// Fallback during config compilation when logger is unavailable
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Are invitations disabled globally
|
||||
* When true, workspace invitations are disabled for all users
|
||||
|
||||
122
apps/sim/tools/execute-command/execute.ts
Normal file
122
apps/sim/tools/execute-command/execute.ts
Normal file
@@ -0,0 +1,122 @@
|
||||
import { DEFAULT_EXECUTION_TIMEOUT_MS } from '@/lib/execution/constants'
|
||||
import type { ExecuteCommandInput, ExecuteCommandOutput } from '@/tools/execute-command/types'
|
||||
import type { ToolConfig } from '@/tools/types'
|
||||
|
||||
export const executeCommandRunTool: ToolConfig<ExecuteCommandInput, ExecuteCommandOutput> = {
|
||||
id: 'execute_command_run',
|
||||
name: 'Execute Command',
|
||||
description:
|
||||
'Execute a shell command on the host machine. Only available for self-hosted deployments with EXECUTE_COMMAND_ENABLED=true.',
|
||||
version: '1.0.0',
|
||||
|
||||
params: {
|
||||
command: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
visibility: 'user-or-llm',
|
||||
description: 'The shell command to execute on the host machine',
|
||||
},
|
||||
timeout: {
|
||||
type: 'number',
|
||||
required: false,
|
||||
visibility: 'user-or-llm',
|
||||
description: 'Execution timeout in milliseconds',
|
||||
default: DEFAULT_EXECUTION_TIMEOUT_MS,
|
||||
},
|
||||
workingDirectory: {
|
||||
type: 'string',
|
||||
required: false,
|
||||
visibility: 'user-or-llm',
|
||||
description: 'Working directory for the command. Defaults to the server process cwd.',
|
||||
},
|
||||
envVars: {
|
||||
type: 'object',
|
||||
required: false,
|
||||
visibility: 'hidden',
|
||||
description: 'Environment variables to make available during execution',
|
||||
default: {},
|
||||
},
|
||||
blockData: {
|
||||
type: 'object',
|
||||
required: false,
|
||||
visibility: 'hidden',
|
||||
description: 'Block output data for variable resolution',
|
||||
default: {},
|
||||
},
|
||||
blockNameMapping: {
|
||||
type: 'object',
|
||||
required: false,
|
||||
visibility: 'hidden',
|
||||
description: 'Mapping of block names to block IDs',
|
||||
default: {},
|
||||
},
|
||||
blockOutputSchemas: {
|
||||
type: 'object',
|
||||
required: false,
|
||||
visibility: 'hidden',
|
||||
description: 'Mapping of block IDs to their output schemas for validation',
|
||||
default: {},
|
||||
},
|
||||
workflowVariables: {
|
||||
type: 'object',
|
||||
required: false,
|
||||
visibility: 'hidden',
|
||||
description: 'Workflow variables for <variable.name> resolution',
|
||||
default: {},
|
||||
},
|
||||
},
|
||||
|
||||
request: {
|
||||
url: '/api/tools/execute-command/run',
|
||||
method: 'POST',
|
||||
headers: () => ({
|
||||
'Content-Type': 'application/json',
|
||||
}),
|
||||
body: (params: ExecuteCommandInput) => ({
|
||||
command: params.command,
|
||||
timeout: params.timeout || DEFAULT_EXECUTION_TIMEOUT_MS,
|
||||
workingDirectory: params.workingDirectory,
|
||||
envVars: params.envVars || {},
|
||||
workflowVariables: params.workflowVariables || {},
|
||||
blockData: params.blockData || {},
|
||||
blockNameMapping: params.blockNameMapping || {},
|
||||
blockOutputSchemas: params.blockOutputSchemas || {},
|
||||
workflowId: params._context?.workflowId,
|
||||
}),
|
||||
},
|
||||
|
||||
transformResponse: async (response: Response): Promise<ExecuteCommandOutput> => {
|
||||
const result = await response.json()
|
||||
|
||||
if (!result.success) {
|
||||
return {
|
||||
success: false,
|
||||
output: {
|
||||
stdout: result.output?.stdout || '',
|
||||
stderr: result.output?.stderr || '',
|
||||
exitCode: result.output?.exitCode ?? 1,
|
||||
},
|
||||
error: result.error,
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
output: {
|
||||
stdout: result.output.stdout,
|
||||
stderr: result.output.stderr,
|
||||
exitCode: result.output.exitCode,
|
||||
},
|
||||
}
|
||||
},
|
||||
|
||||
outputs: {
|
||||
stdout: { type: 'string', description: 'Standard output from the command' },
|
||||
stderr: { type: 'string', description: 'Standard error output from the command' },
|
||||
exitCode: { type: 'number', description: 'Exit code of the command (0 = success)' },
|
||||
error: {
|
||||
type: 'string',
|
||||
description: 'Error message if the command timed out or exceeded buffer limits',
|
||||
},
|
||||
},
|
||||
}
|
||||
2
apps/sim/tools/execute-command/index.ts
Normal file
2
apps/sim/tools/execute-command/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { executeCommandRunTool } from '@/tools/execute-command/execute'
|
||||
export type { ExecuteCommandInput, ExecuteCommandOutput } from '@/tools/execute-command/types'
|
||||
24
apps/sim/tools/execute-command/types.ts
Normal file
24
apps/sim/tools/execute-command/types.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import type { ToolResponse } from '@/tools/types'
|
||||
|
||||
export interface ExecuteCommandInput {
|
||||
command: string
|
||||
timeout?: number
|
||||
workingDirectory?: string
|
||||
envVars?: Record<string, string>
|
||||
workflowVariables?: Record<string, unknown>
|
||||
blockData?: Record<string, unknown>
|
||||
blockNameMapping?: Record<string, string>
|
||||
blockOutputSchemas?: Record<string, Record<string, unknown>>
|
||||
_context?: {
|
||||
workflowId?: string
|
||||
userId?: string
|
||||
}
|
||||
}
|
||||
|
||||
export interface ExecuteCommandOutput extends ToolResponse {
|
||||
output: {
|
||||
stdout: string
|
||||
stderr: string
|
||||
exitCode: number
|
||||
}
|
||||
}
|
||||
@@ -433,6 +433,7 @@ import {
|
||||
exaResearchTool,
|
||||
exaSearchTool,
|
||||
} from '@/tools/exa'
|
||||
import { executeCommandRunTool } from '@/tools/execute-command'
|
||||
import { fileParserV2Tool, fileParserV3Tool, fileParseTool } from '@/tools/file'
|
||||
import {
|
||||
firecrawlAgentTool,
|
||||
@@ -2337,6 +2338,7 @@ export const tools: Record<string, ToolConfig> = {
|
||||
webhook_request: webhookRequestTool,
|
||||
huggingface_chat: huggingfaceChatTool,
|
||||
llm_chat: llmChatTool,
|
||||
execute_command_run: executeCommandRunTool,
|
||||
function_execute: functionExecuteTool,
|
||||
gamma_generate: gammaGenerateTool,
|
||||
gamma_generate_from_template: gammaGenerateFromTemplateTool,
|
||||
|
||||
@@ -206,6 +206,10 @@ app:
|
||||
DISABLE_PUBLIC_API: "" # Set to "true" to disable public API toggle globally
|
||||
NEXT_PUBLIC_DISABLE_PUBLIC_API: "" # Set to "true" to hide public API toggle in UI
|
||||
|
||||
# Execute Command (Self-Hosted Only - runs shell commands on host machine)
|
||||
EXECUTE_COMMAND_ENABLED: "" # Set to "true" to enable shell command execution in workflows (self-hosted only)
|
||||
NEXT_PUBLIC_EXECUTE_COMMAND_ENABLED: "" # Set to "true" to show Execute Command block in UI
|
||||
|
||||
# SSO Configuration (Enterprise Single Sign-On)
|
||||
# Set to "true" AFTER running the SSO registration script
|
||||
SSO_ENABLED: "" # Enable SSO authentication ("true" to enable)
|
||||
|
||||
Reference in New Issue
Block a user