Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
165 changes: 165 additions & 0 deletions apps/sim/app/api/tools/command/exec/route.ts
Original file line number Diff line number Diff line change
@@ -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<ExecutionResult> {
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,
});
});
});
}
86 changes: 86 additions & 0 deletions apps/sim/blocks/blocks/command.ts
Original file line number Diff line number Diff line change
@@ -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<string, any>) => {
const transformed: Record<string, any> = {
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" },
},
};
2 changes: 2 additions & 0 deletions apps/sim/blocks/registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -162,6 +163,7 @@ export const registry: Record<string, BlockConfig> = {
chat_trigger: ChatTriggerBlock,
circleback: CirclebackBlock,
clay: ClayBlock,
command: commandBlock,
condition: ConditionBlock,
confluence: ConfluenceBlock,
cursor: CursorBlock,
Expand Down
61 changes: 61 additions & 0 deletions apps/sim/tools/command/chat.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
import type { CommandInput, CommandOutput } from '@/tools/command/types'
import type { ToolConfig } from '@/tools/types'

export const commandExecTool: ToolConfig<CommandInput, CommandOutput> = {
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' },
},
};
2 changes: 2 additions & 0 deletions apps/sim/tools/command/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
export * from './chat'
export * from './types'
16 changes: 16 additions & 0 deletions apps/sim/tools/command/types.ts
Original file line number Diff line number Diff line change
@@ -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
}
2 changes: 2 additions & 0 deletions apps/sim/tools/registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1167,6 +1167,7 @@ import {
sshWriteFileContentTool,
} from '@/tools/ssh'
import { stagehandAgentTool, stagehandExtractTool } from '@/tools/stagehand'
import { commandExecTool } from '@/tools/command'
import {
stripeCancelPaymentIntentTool,
stripeCancelSubscriptionTool,
Expand Down Expand Up @@ -2020,6 +2021,7 @@ export const tools: Record<string, ToolConfig> = {
thinking_tool: thinkingTool,
stagehand_extract: stagehandExtractTool,
stagehand_agent: stagehandAgentTool,
command_exec: commandExecTool,
mem0_add_memories: mem0AddMemoriesTool,
mem0_search_memories: mem0SearchMemoriesTool,
mem0_get_memories: mem0GetMemoriesTool,
Expand Down