Skip to content

Commit 493e678

Browse files
committed
fix(mothership): export chat sandbox files through workspace storage
1 parent ddcf0c8 commit 493e678

4 files changed

Lines changed: 123 additions & 20 deletions

File tree

‎apps/sim/lib/function-execution/execute-request.test.ts‎

Lines changed: 76 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,6 @@
33
*/
44
import { spawnSync } from 'node:child_process'
55
import { readFileSync } from 'node:fs'
6-
import { resolve } from 'node:path'
76
import {
87
createMockRequest,
98
envFlagsMock,
@@ -2085,6 +2084,81 @@ describe('Function execution request', () => {
20852084
expect(runtimePayload).not.toContain('__simSandboxFileMount')
20862085
})
20872086

2087+
it.each(['python', 'javascript', 'shell'])(
2088+
'exports a chat %s harvest through workspace policy without workflow context',
2089+
async (language) => {
2090+
envFlagsMock.isMothershipSandboxEnabled = true
2091+
hybridAuthMockFns.mockCheckInternalAuth.mockResolvedValue({
2092+
success: true,
2093+
userId: 'user-123',
2094+
authType: 'internal_jwt',
2095+
sandboxProfile: 'mothership',
2096+
})
2097+
const bytes = Buffer.from([0, 255, 13, 10, 42])
2098+
const runtimeResult = {
2099+
result: 'kept-result',
2100+
stdout: 'kept-stdout',
2101+
sandboxId: 'sbx',
2102+
collectedFiles: [
2103+
{
2104+
path: '/tmp/sim/outputs/call-test/report.txt',
2105+
relativePath: 'report.txt',
2106+
contentBase64: bytes.toString('base64'),
2107+
byteLength: bytes.length,
2108+
},
2109+
],
2110+
}
2111+
const sandbox = language === 'shell' ? mockExecuteShellInSandbox : mockExecuteInSandbox
2112+
sandbox.mockResolvedValueOnce(runtimeResult)
2113+
const response = await POST(
2114+
createMockRequest('POST', {
2115+
code: 'x',
2116+
language,
2117+
workspaceId: 'workspace-1',
2118+
sandboxSessionKey: 'same-chat',
2119+
})
2120+
)
2121+
const data = await response.json()
2122+
expect(response.status).toBe(200)
2123+
expect(data.output.result).toBe('kept-result')
2124+
expect(data.output.stdout).toBe('kept-stdout')
2125+
expect(data.resources).toEqual([
2126+
expect.objectContaining({ type: 'file', path: 'files/report.txt' }),
2127+
])
2128+
expect(mockWriteWorkspaceFileByPath).toHaveBeenCalledWith(
2129+
expect.objectContaining({
2130+
workspaceId: 'workspace-1',
2131+
target: expect.objectContaining({ path: 'files/report.txt', mode: 'create' }),
2132+
buffer: bytes,
2133+
})
2134+
)
2135+
expect(mockUploadFile).not.toHaveBeenCalled()
2136+
}
2137+
)
2138+
2139+
it('still requires workflow context for ordinary Function file harvests', async () => {
2140+
envFlagsMock.isRemoteSandboxEnabled = true
2141+
mockExecuteInSandbox.mockResolvedValueOnce({
2142+
result: null,
2143+
stdout: '',
2144+
sandboxId: 'sbx',
2145+
collectedFiles: [
2146+
{
2147+
path: '/tmp/sim/outputs/a.txt',
2148+
relativePath: 'a.txt',
2149+
contentBase64: 'YQ==',
2150+
byteLength: 1,
2151+
},
2152+
],
2153+
})
2154+
const response = await POST(
2155+
createMockRequest('POST', { code: 'x', language: 'python', workspaceId: 'workspace-1' })
2156+
)
2157+
expect(response.status).toBe(400)
2158+
expect((await response.json()).error).toContain('workflow, and execution context')
2159+
expect(mockWriteWorkspaceFileByPath).not.toHaveBeenCalled()
2160+
})
2161+
20882162
it('gives overlapping calls in one persistent workbench distinct automatic export directories', async () => {
20892163
envFlagsMock.isMothershipSandboxEnabled = true
20902164
hybridAuthMockFns.mockCheckInternalAuth.mockResolvedValue({
@@ -2352,7 +2426,7 @@ describe('Function execution request', () => {
23522426
const archiveBase64 =
23532427
'UEsDBBQAAAAIAAAAIQAcWyFBIAAAAB8AAAAMAAAAcHJldmlldy5odG1ss8kwtHNLzcnJLy9WcM4vzUvOzFEIT03Nzqm00QdKAQBQSwECFAMUAAAACAAAACEAHFshQSAAAAAfAAAADAAAAAAAAAAAAAAAgAEAAAAAcHJldmlldy5odG1sUEsFBgAAAAABAAEAOgAAAEoAAAAAAA=='
23542428
const source = readFileSync(
2355-
resolve(process.cwd(), 'lib/execution/remote-sandbox/fixtures/fellows-council-weekly.py'),
2429+
new URL('../execution/remote-sandbox/fixtures/fellows-council-weekly.py', import.meta.url),
23562430
'utf8'
23572431
)
23582432
mockExecuteInSandbox.mockResolvedValueOnce({

‎apps/sim/lib/function-execution/execute-request.ts‎

Lines changed: 42 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1518,7 +1518,7 @@ async function maybeExportSandboxFileToWorkspace(args: {
15181518
outputSandboxPath?: string
15191519
overwriteFileId?: string
15201520
outputMode?: 'create' | 'overwrite'
1521-
exportedFileContent?: string
1521+
exportedFileContent?: string | Buffer
15221522
result: unknown
15231523
stdout: string
15241524
executionTime: number
@@ -1599,9 +1599,9 @@ async function maybeExportSandboxFileToWorkspace(args: {
15991599
cost
16001600
)
16011601
}
1602-
const fileBuffer = isBinary
1603-
? Buffer.from(exportedFileContent, 'base64')
1604-
: Buffer.from(exportedFileContent, 'utf-8')
1602+
const fileBuffer = Buffer.isBuffer(exportedFileContent)
1603+
? exportedFileContent
1604+
: Buffer.from(exportedFileContent, isBinary ? 'base64' : 'utf-8')
16051605
const secretProvenance = await getOutputFileSecretProvenance(fileBuffer, isBinary, routeContext, {
16061606
userId: authUserId,
16071607
workspaceId: resolvedWorkspaceId,
@@ -1691,8 +1691,8 @@ async function maybeExportSandboxFilesToWorkspace(args: {
16911691
workflowId?: string
16921692
workspaceId?: string
16931693
outputFiles: OutputFileDeclaration[]
1694-
exportedFiles?: Record<string, string>
1695-
exportedFileContent?: string
1694+
exportedFiles?: Record<string, string | Buffer>
1695+
exportedFileContent?: string | Buffer
16961696
result: unknown
16971697
stdout: string
16981698
executionTime: number
@@ -1780,7 +1780,9 @@ async function maybeExportSandboxFilesToWorkspace(args: {
17801780
args.cost
17811781
)
17821782
}
1783-
const scanBuffer = isBinary ? Buffer.from(content, 'base64') : Buffer.from(content, 'utf-8')
1783+
const scanBuffer = Buffer.isBuffer(content)
1784+
? content
1785+
: Buffer.from(content, isBinary ? 'base64' : 'utf-8')
17841786
const secretProvenance = await getOutputFileSecretProvenance(
17851787
scanBuffer,
17861788
isBinary,
@@ -1846,9 +1848,9 @@ async function maybeExportSandboxFilesToWorkspace(args: {
18461848
const writtenFiles = []
18471849
try {
18481850
for (const prepared of preparedFiles) {
1849-
const buffer = prepared.isBinary
1850-
? Buffer.from(prepared.content, 'base64')
1851-
: Buffer.from(prepared.content, 'utf-8')
1851+
const buffer = Buffer.isBuffer(prepared.content)
1852+
? prepared.content
1853+
: Buffer.from(prepared.content, prepared.isBinary ? 'base64' : 'utf-8')
18521854
let previousSize: number | undefined
18531855
let unchanged = false
18541856
if (prepared.target.mode === 'overwrite') {
@@ -2007,20 +2009,40 @@ async function discardUploadedExecutionFiles(files: readonly UserFile[]): Promis
20072009
}
20082010
}
20092011

2010-
async function collectExecutionOutputFiles(args: {
2012+
async function collectSandboxOutputFiles(args: {
20112013
routeContext: FunctionRouteExecutionContext
20122014
authUserId: string
20132015
workflowId?: string
20142016
workspaceId?: string
20152017
executionId?: string
20162018
collectedFiles: SandboxCollectedFile[]
2019+
sandboxProfile?: 'mothership'
2020+
result: unknown
20172021
stdout: string
20182022
executionTime: number
20192023
cost?: FunctionExecutionCost
20202024
}): Promise<{ files: UserFile[] } | { response: NextResponse }> {
20212025
const { routeContext, collectedFiles } = args
20222026
if (collectedFiles.length === 0) return { files: [] }
20232027

2028+
/** Chat exports use workspace ownership; only workflow exports need an execution scope. */
2029+
if (args.sandboxProfile === 'mothership') {
2030+
const response = await maybeExportSandboxFilesToWorkspace({
2031+
...args,
2032+
outputFiles: collectedFiles.map((file) => ({
2033+
path: `files/${collectedFileName(file.relativePath)}`,
2034+
sandboxPath: file.path,
2035+
mode: 'create',
2036+
mimeType: getMimeTypeFromExtension(getFileExtension(file.relativePath)),
2037+
})),
2038+
exportedFiles: Object.fromEntries(
2039+
collectedFiles.map((file) => [file.path, Buffer.from(file.contentBase64, 'base64')])
2040+
),
2041+
})
2042+
if (!response) throw new Error('Collected sandbox files require an export response')
2043+
return { response }
2044+
}
2045+
20242046
const resolvedWorkspaceId =
20252047
args.workspaceId ||
20262048
(args.workflowId ? (await getWorkflowById(args.workflowId))?.workspaceId : undefined)
@@ -2679,13 +2701,15 @@ export async function executeFunctionRequest(
26792701
}
26802702
}
26812703

2682-
const shellOutputFiles = await collectExecutionOutputFiles({
2704+
const shellOutputFiles = await collectSandboxOutputFiles({
26832705
routeContext,
26842706
authUserId: auth.attributedUserId,
26852707
workflowId,
26862708
workspaceId,
26872709
executionId,
26882710
collectedFiles: shellCollectedFiles ?? [],
2711+
sandboxProfile: auth.sandboxProfile,
2712+
result: shellResult,
26892713
stdout: shellStdout,
26902714
executionTime,
26912715
cost: shellCost,
@@ -2843,13 +2867,15 @@ export async function executeFunctionRequest(
28432867
}
28442868
}
28452869

2846-
const jsOutputFiles = await collectExecutionOutputFiles({
2870+
const jsOutputFiles = await collectSandboxOutputFiles({
28472871
routeContext,
28482872
authUserId: auth.attributedUserId,
28492873
workflowId,
28502874
workspaceId,
28512875
executionId,
28522876
collectedFiles: jsCollectedFiles ?? [],
2877+
sandboxProfile: auth.sandboxProfile,
2878+
result: e2bResult,
28532879
stdout,
28542880
executionTime,
28552881
cost: sandboxCost,
@@ -2969,13 +2995,15 @@ export async function executeFunctionRequest(
29692995
}
29702996
}
29712997

2972-
const pythonOutputFiles = await collectExecutionOutputFiles({
2998+
const pythonOutputFiles = await collectSandboxOutputFiles({
29732999
routeContext,
29743000
authUserId: auth.attributedUserId,
29753001
workflowId,
29763002
workspaceId,
29773003
executionId,
29783004
collectedFiles: pythonCollectedFiles ?? [],
3005+
sandboxProfile: auth.sandboxProfile,
3006+
result: e2bResult,
29793007
stdout,
29803008
executionTime,
29813009
cost: sandboxCost,

‎apps/sim/lib/mothership/tools/handlers/function-execute-session.test.ts‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -104,15 +104,15 @@ describe.each([
104104
afterEach(clearHandlers)
105105

106106
it.each([
107-
{ timeout: undefined, expected: 10_000 },
107+
{ timeout: undefined, expected: 60_000 },
108108
{ timeout: 0.5, expected: 500 },
109109
{ timeout: 7, expected: 7_000 },
110110
{ timeout: '90', expected: 90_000 },
111111
{ timeout: 300, expected: 300_000 },
112112
{ timeout: 601, expected: 300_000 },
113113
{ timeout: 45_000, expected: 300_000 },
114-
{ timeout: 0, expected: 10_000 },
115-
{ timeout: 'invalid', expected: 10_000 },
114+
{ timeout: 0, expected: 60_000 },
115+
{ timeout: 'invalid', expected: 60_000 },
116116
])('maps $timeout seconds to $expected milliseconds once', async ({ timeout, expected }) => {
117117
const params = { code: 'return 1', language: 'javascript', timeout }
118118
const result = await executeTool(toolId, params, {

‎apps/sim/lib/mothership/tools/handlers/function-execute.ts‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -54,7 +54,8 @@ import { executeTool as executeAppTool } from '@/tools'
5454
const logger = createLogger('CopilotFunctionExecute')
5555

5656
const MAX_MOUNTED_FILES = 500
57-
const DEFAULT_TIMEOUT_SECONDS = 10
57+
/** The call budget includes cold allocation and mounting, before the user's code starts. */
58+
const DEFAULT_TIMEOUT_SECONDS = 60
5859
const MAX_TIMEOUT_SECONDS = 300
5960

6061
/** Keeps mount bytes, canonical classification and budget together until they enter the runtime. */

0 commit comments

Comments
 (0)