diff --git a/apps/sim/app/api/tools/command/exec/route.ts b/apps/sim/app/api/tools/command/exec/route.ts new file mode 100644 index 0000000000..ff2697b881 --- /dev/null +++ b/apps/sim/app/api/tools/command/exec/route.ts @@ -0,0 +1,165 @@ +import { createLogger } from '@sim/logger' +import { spawn } from "child_process"; +import { NextRequest, NextResponse } from "next/server"; +import type { CommandInput, CommandOutput } from "@/tools/command/types"; +import { getSession } from '@/lib/auth' + +const logger = createLogger('CommandExecAPI') + +export async function POST(request: NextRequest) { + try { + const session = await getSession() + if (!session?.user?.id) { + return NextResponse.json( + { error: 'Unauthorized' }, + { status: 401 }, + ) + } + + const params: CommandInput = await request.json() + +import { validatePathSegment } from '@/lib/core/security/input-validation' + + // Validate input + if (!params.command) { + return NextResponse.json( + { error: "Command is required" }, + { status: 400 }, + ) + } + + // Validate workingDirectory if provided + if (params.workingDirectory) { + const validation = validatePathSegment(params.workingDirectory, { + paramName: 'workingDirectory', + allowDots: true // Allow relative paths like ../ + }) + if (!validation.isValid) { + return NextResponse.json( + { error: validation.error }, + { status: 400 }, + ) + } + } + + // Validate shell if provided - only allow safe shells + const allowedShells = ['/bin/bash', '/bin/sh', '/bin/zsh'] + if (params.shell && !allowedShells.includes(params.shell)) { + return NextResponse.json( + { error: 'Invalid shell. Allowed shells: ' + allowedShells.join(', ') }, + { status: 400 }, + ) + } + + // Set default values + const workingDirectory = params.workingDirectory || process.cwd() + const timeout = params.timeout || 30000 + const shell = params.shell || '/bin/bash' + // Execute command + const startTime = Date.now(); + const result = await executeCommand( + params.command, + workingDirectory, + timeout, + shell, + ); + const duration = Date.now() - startTime; + + const output: CommandOutput = { + stdout: result.stdout, + stderr: result.stderr, + exitCode: result.exitCode, + duration, + command: params.command, + workingDirectory, + timedOut: result.timedOut, + }; + + return NextResponse.json(output); + } catch (error) { + logger.error('Command execution error:', { error }) + return NextResponse.json( + { + error: error instanceof Error ? error.message : "Unknown error occurred", + }, + { status: 500 }, + ); + } +} + +interface ExecutionResult { + stdout: string; + stderr: string; + exitCode: number; + timedOut: boolean; +} + +function executeCommand( + command: string, + workingDirectory: string, + timeout: number, + shell: string, +): Promise { + return new Promise((resolve) => { + let stdout = ""; + let stderr = ""; + let timedOut = false; + + // Merge environment variables + const env = { + ...process.env, + }; + + // Spawn the process + const proc = spawn(shell, ["-c", command], { + cwd: workingDirectory, + env, + timeout, + }); + + // Set up timeout + const timeoutId = setTimeout(() => { + timedOut = true; + proc.kill("SIGTERM"); + + // Force kill after 5 seconds if still running + setTimeout(() => { + if (!proc.killed) { + proc.kill("SIGKILL"); + } + }, 5000); + }, timeout); + + // Capture stdout + proc.stdout?.on("data", (data: Buffer) => { + stdout += data.toString(); + }); + + // Capture stderr + proc.stderr?.on("data", (data: Buffer) => { + stderr += data.toString(); + }); + + // Handle process completion + proc.on("close", (code: number | null) => { + clearTimeout(timeoutId); + resolve({ + stdout, + stderr, + exitCode: code ?? -1, + timedOut, + }); + }); + + // Handle process errors + proc.on("error", (error: Error) => { + clearTimeout(timeoutId); + resolve({ + stdout, + stderr: stderr + `\nProcess error: ${error.message}`, + exitCode: -1, + timedOut, + }); + }); + }); +} diff --git a/apps/sim/blocks/blocks/command.ts b/apps/sim/blocks/blocks/command.ts new file mode 100644 index 0000000000..92bb26c188 --- /dev/null +++ b/apps/sim/blocks/blocks/command.ts @@ -0,0 +1,86 @@ +import { Terminal } from "lucide-react"; +import type { BlockConfig } from "@/blocks/types"; + +export const commandBlock: BlockConfig = { + type: "command", + name: "Command", + description: "Execute bash commands in a specified working directory with optional timeout and shell configuration.", + category: "tools", + bgColor: "#10B981", + icon: Terminal, + subBlocks: [ + { + id: "command", + title: "Command", + type: "long-input", + placeholder: 'echo "Hello World"', + required: true, + }, + { + id: "workingDirectory", + title: "Working Directory", + type: "short-input", + placeholder: "/path/to/directory (optional)", + required: false, + }, + { + id: "timeout", + title: "Timeout (ms)", + type: "short-input", + placeholder: "30000", + value: () => "30000", + required: false, + }, + { + id: "shell", + title: "Shell", + type: "short-input", + placeholder: "/bin/bash", + value: () => "/bin/bash", + required: false, + }, + ], + tools: { + access: ["command_exec"], + config: { + tool: () => "command_exec", + params: (params: Record) => { + const transformed: Record = { + command: params.command, + }; + + if (params.workingDirectory) { + transformed.workingDirectory = params.workingDirectory; + } + + if (params.timeout) { + const timeoutNum = Number.parseInt(params.timeout as string, 10); + if (!Number.isNaN(timeoutNum)) { + transformed.timeout = timeoutNum; + } + } + + if (params.shell) { + transformed.shell = params.shell; + } + + return transformed; + }, + }, + }, + inputs: { + command: { type: "string", description: "The bash command to execute" }, + workingDirectory: { type: "string", description: "Directory where the command will be executed" }, + timeout: { type: "number", description: "Maximum execution time in milliseconds" }, + shell: { type: "string", description: "Shell to use for execution" }, + }, + outputs: { + stdout: { type: "string", description: "Standard output from the command" }, + stderr: { type: "string", description: "Standard error from the command" }, + exitCode: { type: "number", description: "Command exit code (0 = success)" }, + duration: { type: "number", description: "Execution time in milliseconds" }, + command: { type: "string", description: "The executed command" }, + workingDirectory: { type: "string", description: "The directory where command was executed" }, + timedOut: { type: "boolean", description: "Whether the command exceeded the timeout" }, + }, +}; diff --git a/apps/sim/blocks/registry.ts b/apps/sim/blocks/registry.ts index 8a4d75121f..f0d1eb30e5 100644 --- a/apps/sim/blocks/registry.ts +++ b/apps/sim/blocks/registry.ts @@ -13,6 +13,7 @@ import { CalendlyBlock } from '@/blocks/blocks/calendly' import { ChatTriggerBlock } from '@/blocks/blocks/chat_trigger' import { CirclebackBlock } from '@/blocks/blocks/circleback' import { ClayBlock } from '@/blocks/blocks/clay' +import { commandBlock } from '@/blocks/blocks/command' import { ConditionBlock } from '@/blocks/blocks/condition' import { ConfluenceBlock } from '@/blocks/blocks/confluence' import { CursorBlock } from '@/blocks/blocks/cursor' @@ -162,6 +163,7 @@ export const registry: Record = { chat_trigger: ChatTriggerBlock, circleback: CirclebackBlock, clay: ClayBlock, + command: commandBlock, condition: ConditionBlock, confluence: ConfluenceBlock, cursor: CursorBlock, diff --git a/apps/sim/tools/command/chat.ts b/apps/sim/tools/command/chat.ts new file mode 100644 index 0000000000..83e80de8c9 --- /dev/null +++ b/apps/sim/tools/command/chat.ts @@ -0,0 +1,61 @@ +import type { CommandInput, CommandOutput } from '@/tools/command/types' +import type { ToolConfig } from '@/tools/types' + +export const commandExecTool: ToolConfig = { + id: 'command_exec', + name: 'Command', + description: 'Execute bash commands with custom environment variables', + version: '1.0.0', + + params: { + command: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'The bash command to execute', + }, + workingDirectory: { + type: 'string', + required: false, + visibility: 'user-only', + description: 'Directory where the command will be executed', + }, + timeout: { + type: 'number', + required: false, + visibility: 'user-only', + description: 'Maximum execution time in milliseconds', + }, + shell: { + type: 'string', + required: false, + visibility: 'user-only', + description: 'Shell to use for execution', + }, + }, + + request: { + url: '/api/tools/command/exec', + method: 'POST', + headers: () => ({ + 'Content-Type': 'application/json', + }), + body: (params) => ({ + command: params.command, + workingDirectory: params.workingDirectory, + timeout: params.timeout || 30000, + shell: params.shell || '/bin/bash', + }), + }, + }, + + outputs: { + stdout: { type: 'string', description: 'Standard output from the command' }, + stderr: { type: 'string', description: 'Standard error from the command' }, + exitCode: { type: 'number', description: 'Command exit code (0 = success)' }, + duration: { type: 'number', description: 'Execution time in milliseconds' }, + command: { type: 'string', description: 'The executed command' }, + workingDirectory: { type: 'string', description: 'The directory where command was executed' }, + timedOut: { type: 'boolean', description: 'Whether the command exceeded the timeout' }, + }, +}; diff --git a/apps/sim/tools/command/index.ts b/apps/sim/tools/command/index.ts new file mode 100644 index 0000000000..20e4d42ac5 --- /dev/null +++ b/apps/sim/tools/command/index.ts @@ -0,0 +1,2 @@ +export * from './chat' +export * from './types' diff --git a/apps/sim/tools/command/types.ts b/apps/sim/tools/command/types.ts new file mode 100644 index 0000000000..2e356b95fe --- /dev/null +++ b/apps/sim/tools/command/types.ts @@ -0,0 +1,16 @@ +export interface CommandInput { + command: string; // The bash command to execute + workingDirectory?: string; // Optional working directory (defaults to workspace root) + timeout?: number; // Optional timeout in milliseconds (default: 30000) + shell?: string; // Optional shell to use (default: /bin/bash) +} + +export interface CommandOutput { + stdout: string; // Standard output from the command + stderr: string; // Standard error from the command + exitCode: number; // Exit code of the command + duration: number; // Execution duration in milliseconds + command: string; // The executed command + workingDirectory: string; // The directory where command was executed + timedOut: boolean; // Whether the command timed out +} diff --git a/apps/sim/tools/registry.ts b/apps/sim/tools/registry.ts index 6d8cb9ec2a..d94193908f 100644 --- a/apps/sim/tools/registry.ts +++ b/apps/sim/tools/registry.ts @@ -1167,6 +1167,7 @@ import { sshWriteFileContentTool, } from '@/tools/ssh' import { stagehandAgentTool, stagehandExtractTool } from '@/tools/stagehand' +import { commandExecTool } from '@/tools/command' import { stripeCancelPaymentIntentTool, stripeCancelSubscriptionTool, @@ -2020,6 +2021,7 @@ export const tools: Record = { thinking_tool: thinkingTool, stagehand_extract: stagehandExtractTool, stagehand_agent: stagehandAgentTool, + command_exec: commandExecTool, mem0_add_memories: mem0AddMemoriesTool, mem0_search_memories: mem0SearchMemoriesTool, mem0_get_memories: mem0GetMemoriesTool,