Skip to content

Commit 7cd4545

Browse files
committed
feat(mothership): add cli execution
1 parent 2548912 commit 7cd4545

File tree

10 files changed

+426
-5
lines changed

10 files changed

+426
-5
lines changed

apps/sim/app/api/function/execute/route.ts

Lines changed: 212 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,18 @@
11
import { createLogger } from '@sim/logger'
22
import { type NextRequest, NextResponse } from 'next/server'
33
import { checkInternalAuth } from '@/lib/auth/hybrid'
4+
import {
5+
FORMAT_TO_CONTENT_TYPE,
6+
normalizeOutputWorkspaceFileName,
7+
resolveOutputFormat,
8+
} from '@/lib/copilot/request/tools/files'
49
import { isE2bEnabled } from '@/lib/core/config/feature-flags'
510
import { generateRequestId } from '@/lib/core/utils/request'
6-
import { executeInE2B } from '@/lib/execution/e2b'
11+
import { executeInE2B, executeShellInE2B } from '@/lib/execution/e2b'
712
import { executeInIsolatedVM } from '@/lib/execution/isolated-vm'
813
import { CodeLanguage, DEFAULT_CODE_LANGUAGE, isValidCodeLanguage } from '@/lib/execution/languages'
14+
import { uploadWorkspaceFile } from '@/lib/uploads/contexts/workspace/workspace-file-manager'
15+
import { getWorkflowById } from '@/lib/workflows/utils'
916
import { escapeRegExp, normalizeName, REFERENCE } from '@/executor/constants'
1017
import { type OutputSchema, resolveBlockReference } from '@/executor/utils/block-reference'
1118
import { formatLiteralForCode } from '@/executor/utils/code-formatting'
@@ -580,6 +587,96 @@ function cleanStdout(stdout: string): string {
580587
return stdout
581588
}
582589

590+
async function maybeExportSandboxFileToWorkspace(args: {
591+
authUserId: string
592+
workflowId?: string
593+
workspaceId?: string
594+
outputPath?: string
595+
outputFormat?: string
596+
outputSandboxPath?: string
597+
exportedFileContent?: string
598+
stdout: string
599+
executionTime: number
600+
}) {
601+
const {
602+
authUserId,
603+
workflowId,
604+
workspaceId,
605+
outputPath,
606+
outputFormat,
607+
outputSandboxPath,
608+
exportedFileContent,
609+
stdout,
610+
executionTime,
611+
} = args
612+
613+
if (!outputSandboxPath) return null
614+
615+
if (!outputPath) {
616+
return NextResponse.json(
617+
{
618+
success: false,
619+
error:
620+
'outputSandboxPath requires outputPath. Set outputPath to the destination workspace file, e.g. "files/result.csv".',
621+
output: { result: null, stdout: cleanStdout(stdout), executionTime },
622+
},
623+
{ status: 400 }
624+
)
625+
}
626+
627+
const resolvedWorkspaceId =
628+
workspaceId || (workflowId ? (await getWorkflowById(workflowId))?.workspaceId : undefined)
629+
630+
if (!resolvedWorkspaceId) {
631+
return NextResponse.json(
632+
{
633+
success: false,
634+
error: 'Workspace context required to save sandbox file to workspace',
635+
output: { result: null, stdout: cleanStdout(stdout), executionTime },
636+
},
637+
{ status: 400 }
638+
)
639+
}
640+
641+
if (exportedFileContent === undefined) {
642+
return NextResponse.json(
643+
{
644+
success: false,
645+
error: `Sandbox file "${outputSandboxPath}" was not found or could not be read`,
646+
output: { result: null, stdout: cleanStdout(stdout), executionTime },
647+
},
648+
{ status: 500 }
649+
)
650+
}
651+
652+
const fileName = normalizeOutputWorkspaceFileName(outputPath)
653+
const format = resolveOutputFormat(fileName, outputFormat)
654+
const contentType = FORMAT_TO_CONTENT_TYPE[format]
655+
const uploaded = await uploadWorkspaceFile(
656+
resolvedWorkspaceId,
657+
authUserId,
658+
Buffer.from(exportedFileContent, 'utf-8'),
659+
fileName,
660+
contentType
661+
)
662+
663+
return NextResponse.json({
664+
success: true,
665+
output: {
666+
result: {
667+
message: `Sandbox file exported to files/${fileName}`,
668+
fileId: uploaded.id,
669+
fileName,
670+
downloadUrl: uploaded.url,
671+
sandboxPath: outputSandboxPath,
672+
},
673+
stdout: cleanStdout(stdout),
674+
executionTime,
675+
},
676+
resources: [{ type: 'file', id: uploaded.id, title: fileName }],
677+
})
678+
}
679+
583680
export async function POST(req: NextRequest) {
584681
const requestId = generateRequestId()
585682
const startTime = Date.now()
@@ -603,12 +700,16 @@ export async function POST(req: NextRequest) {
603700
params = {},
604701
timeout = DEFAULT_EXECUTION_TIMEOUT_MS,
605702
language = DEFAULT_CODE_LANGUAGE,
703+
outputPath,
704+
outputFormat,
705+
outputSandboxPath,
606706
envVars = {},
607707
blockData = {},
608708
blockNameMapping = {},
609709
blockOutputSchemas = {},
610710
workflowVariables = {},
611711
workflowId,
712+
workspaceId,
612713
isCustomTool = false,
613714
_sandboxFiles,
614715
} = body
@@ -652,6 +753,82 @@ export async function POST(req: NextRequest) {
652753
hasImports = jsImports.trim().length > 0 || hasRequireStatements
653754
}
654755

756+
if (lang === CodeLanguage.Shell) {
757+
if (!isE2bEnabled) {
758+
throw new Error(
759+
'Shell execution requires E2B to be enabled. Please contact your administrator to enable E2B.'
760+
)
761+
}
762+
763+
const shellEnvs: Record<string, string> = {}
764+
for (const [k, v] of Object.entries(envVars)) {
765+
shellEnvs[k] = String(v)
766+
}
767+
for (const [k, v] of Object.entries(contextVariables)) {
768+
shellEnvs[k] = String(v)
769+
}
770+
771+
logger.info(`[${requestId}] E2B shell execution`, {
772+
enabled: isE2bEnabled,
773+
hasApiKey: Boolean(process.env.E2B_API_KEY),
774+
envVarCount: Object.keys(shellEnvs).length,
775+
})
776+
777+
const execStart = Date.now()
778+
const {
779+
result: shellResult,
780+
stdout: shellStdout,
781+
sandboxId,
782+
error: shellError,
783+
exportedFileContent,
784+
} = await executeShellInE2B({
785+
code: resolvedCode,
786+
envs: shellEnvs,
787+
timeoutMs: timeout,
788+
sandboxFiles: _sandboxFiles,
789+
outputSandboxPath,
790+
})
791+
const executionTime = Date.now() - execStart
792+
793+
logger.info(`[${requestId}] E2B shell sandbox`, {
794+
sandboxId,
795+
stdoutPreview: shellStdout?.slice(0, 200),
796+
error: shellError,
797+
executionTime,
798+
})
799+
800+
if (shellError) {
801+
return NextResponse.json(
802+
{
803+
success: false,
804+
error: shellError,
805+
output: { result: null, stdout: cleanStdout(shellStdout), executionTime },
806+
},
807+
{ status: 500 }
808+
)
809+
}
810+
811+
if (outputSandboxPath) {
812+
const fileExportResponse = await maybeExportSandboxFileToWorkspace({
813+
authUserId: auth.userId,
814+
workflowId,
815+
workspaceId,
816+
outputPath,
817+
outputFormat,
818+
outputSandboxPath,
819+
exportedFileContent,
820+
stdout: shellStdout,
821+
executionTime,
822+
})
823+
if (fileExportResponse) return fileExportResponse
824+
}
825+
826+
return NextResponse.json({
827+
success: true,
828+
output: { result: shellResult ?? null, stdout: cleanStdout(shellStdout), executionTime },
829+
})
830+
}
831+
655832
if (lang === CodeLanguage.Python && !isE2bEnabled) {
656833
throw new Error(
657834
'Python execution requires E2B to be enabled. Please contact your administrator to enable E2B, or use JavaScript instead.'
@@ -719,11 +896,13 @@ export async function POST(req: NextRequest) {
719896
stdout: e2bStdout,
720897
sandboxId,
721898
error: e2bError,
899+
exportedFileContent,
722900
} = await executeInE2B({
723901
code: codeForE2B,
724902
language: CodeLanguage.JavaScript,
725903
timeoutMs: timeout,
726904
sandboxFiles: _sandboxFiles,
905+
outputSandboxPath,
727906
})
728907
const executionTime = Date.now() - execStart
729908
stdout += e2bStdout
@@ -752,6 +931,21 @@ export async function POST(req: NextRequest) {
752931
)
753932
}
754933

934+
if (outputSandboxPath) {
935+
const fileExportResponse = await maybeExportSandboxFileToWorkspace({
936+
authUserId: auth.userId,
937+
workflowId,
938+
workspaceId,
939+
outputPath,
940+
outputFormat,
941+
outputSandboxPath,
942+
exportedFileContent,
943+
stdout,
944+
executionTime,
945+
})
946+
if (fileExportResponse) return fileExportResponse
947+
}
948+
755949
return NextResponse.json({
756950
success: true,
757951
output: { result: e2bResult ?? null, stdout: cleanStdout(stdout), executionTime },
@@ -783,11 +977,13 @@ export async function POST(req: NextRequest) {
783977
stdout: e2bStdout,
784978
sandboxId,
785979
error: e2bError,
980+
exportedFileContent,
786981
} = await executeInE2B({
787982
code: codeForE2B,
788983
language: CodeLanguage.Python,
789984
timeoutMs: timeout,
790985
sandboxFiles: _sandboxFiles,
986+
outputSandboxPath,
791987
})
792988
const executionTime = Date.now() - execStart
793989
stdout += e2bStdout
@@ -816,6 +1012,21 @@ export async function POST(req: NextRequest) {
8161012
)
8171013
}
8181014

1015+
if (outputSandboxPath) {
1016+
const fileExportResponse = await maybeExportSandboxFileToWorkspace({
1017+
authUserId: auth.userId,
1018+
workflowId,
1019+
workspaceId,
1020+
outputPath,
1021+
outputFormat,
1022+
outputSandboxPath,
1023+
exportedFileContent,
1024+
stdout,
1025+
executionTime,
1026+
})
1027+
if (fileExportResponse) return fileExportResponse
1028+
}
1029+
8191030
return NextResponse.json({
8201031
success: true,
8211032
output: { result: e2bResult ?? null, stdout: cleanStdout(stdout), executionTime },

apps/sim/lib/copilot/request/tools/files.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -138,6 +138,10 @@ export async function maybeWriteOutputToFile(
138138
const outputPath =
139139
(params?.outputPath as string | undefined) ?? (args?.outputPath as string | undefined)
140140
if (!outputPath) return result
141+
const outputSandboxPath =
142+
(params?.outputSandboxPath as string | undefined) ??
143+
(args?.outputSandboxPath as string | undefined)
144+
if (toolName === FunctionExecute.id && outputSandboxPath) return result
141145

142146
const explicitFormat =
143147
(params?.outputFormat as string | undefined) ?? (args?.outputFormat as string | undefined)
@@ -179,6 +183,7 @@ export async function maybeWriteOutputToFile(
179183
size: buffer.length,
180184
downloadUrl: uploaded.url,
181185
},
186+
resources: [{ type: 'file', id: uploaded.id, title: fileName }],
182187
}
183188
} catch (err) {
184189
const message = err instanceof Error ? err.message : String(err)

apps/sim/lib/copilot/tool-executor/register-handlers.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import {
1212
DeployApi,
1313
DeployChat,
1414
DeployMcp,
15+
FunctionExecute,
1516
GenerateApiKey,
1617
GetBlockOutputs,
1718
GetBlockUpstreamReferences,
@@ -61,6 +62,7 @@ import {
6162
executeRevertToVersion,
6263
executeUpdateWorkspaceMcpServer,
6364
} from '../tools/handlers/deployment/manage'
65+
import { executeFunctionExecute } from '../tools/handlers/function-execute'
6466
import {
6567
executeCompleteJob,
6668
executeCreateJob,
@@ -167,6 +169,7 @@ function buildHandlerMap(): Record<string, ToolHandler> {
167169
[OpenResource.id]: h(executeOpenResource),
168170
[GetPlatformActions.id]: h(executeGetPlatformActions),
169171
[MaterializeFile.id]: h(executeMaterializeFile),
172+
[FunctionExecute.id]: h(executeFunctionExecute),
170173

171174
...buildServerToolHandlers(),
172175
}
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
import { executeTool as executeAppTool } from '@/tools'
2+
import type { ToolExecutionContext, ToolExecutionResult } from '../../tool-executor/types'
3+
4+
export async function executeFunctionExecute(
5+
params: Record<string, unknown>,
6+
context: ToolExecutionContext
7+
): Promise<ToolExecutionResult> {
8+
const enrichedParams = { ...params }
9+
10+
if (context.decryptedEnvVars && Object.keys(context.decryptedEnvVars).length > 0) {
11+
enrichedParams.envVars = {
12+
...context.decryptedEnvVars,
13+
...((enrichedParams.envVars as Record<string, string>) || {}),
14+
}
15+
}
16+
17+
enrichedParams._context = {
18+
...(typeof enrichedParams._context === 'object' && enrichedParams._context !== null
19+
? (enrichedParams._context as object)
20+
: {}),
21+
userId: context.userId,
22+
workflowId: context.workflowId,
23+
workspaceId: context.workspaceId,
24+
chatId: context.chatId,
25+
executionId: context.executionId,
26+
runId: context.runId,
27+
enforceCredentialAccess: true,
28+
}
29+
30+
return executeAppTool('function_execute', enrichedParams, false)
31+
}

apps/sim/lib/core/config/env.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -319,6 +319,7 @@ export const env = createEnv({
319319
// E2B Remote Code Execution
320320
E2B_ENABLED: z.string().optional(), // Enable E2B remote code execution
321321
E2B_API_KEY: z.string().optional(), // E2B API key for sandbox creation
322+
MOTHERSHIP_E2B_TEMPLATE_ID: z.string().optional(), // Custom E2B template with pre-installed CLI tools for shell execution
322323

323324
// Credential Sets (Email Polling) - for self-hosted deployments
324325
CREDENTIAL_SETS_ENABLED: z.boolean().optional(), // Enable credential sets on self-hosted (bypasses plan requirements)

0 commit comments

Comments
 (0)