Skip to content

Commit 8b4e5f0

Browse files
committed
feat(copilot): expose Sim sandboxes to mothership
1 parent 8558c19 commit 8b4e5f0

32 files changed

Lines changed: 1690 additions & 285 deletions

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

Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -291,6 +291,116 @@ describe('Function Execute API Route', () => {
291291
)
292292
})
293293

294+
it.each([
295+
{ language: 'javascript', code: 'return 42' },
296+
{ language: 'python', code: '__sim_result__ = 42' },
297+
])(
298+
'runs trusted Mothership $language in the selected Function-based Sim sandbox',
299+
async ({ language, code }) => {
300+
envFlagsMock.isRemoteSandboxEnabled = true
301+
hybridAuthMockFns.mockCheckInternalAuth.mockResolvedValueOnce({
302+
success: true,
303+
userId: 'user-123',
304+
authType: 'internal_jwt',
305+
sandboxProfile: 'mothership',
306+
})
307+
308+
const response = await POST(
309+
createMockRequest('POST', {
310+
code,
311+
language,
312+
workspaceId: 'workspace-1',
313+
sandboxId: 'sandbox-1',
314+
})
315+
)
316+
317+
expect(response.status).toBe(200)
318+
const request = mockExecuteInSandbox.mock.calls.at(-1)?.[0]
319+
expect(request).toMatchObject({
320+
language,
321+
workspaceId: 'workspace-1',
322+
sandboxId: 'sandbox-1',
323+
})
324+
expect(request).not.toHaveProperty('sandboxKind')
325+
}
326+
)
327+
328+
it('runs trusted Mothership Shell in the selected Sim sandbox', async () => {
329+
envFlagsMock.isRemoteSandboxEnabled = true
330+
hybridAuthMockFns.mockCheckInternalAuth.mockResolvedValueOnce({
331+
success: true,
332+
userId: 'user-123',
333+
authType: 'internal_jwt',
334+
sandboxProfile: 'mothership',
335+
})
336+
337+
const response = await POST(
338+
createMockRequest('POST', {
339+
code: 'kubectl version --client',
340+
language: 'shell',
341+
workspaceId: 'workspace-1',
342+
sandboxId: 'sandbox-1',
343+
})
344+
)
345+
346+
expect(response.status).toBe(200)
347+
const request = mockExecuteShellInSandbox.mock.calls.at(-1)?.[0]
348+
expect(request).toMatchObject({
349+
workspaceId: 'workspace-1',
350+
sandboxId: 'sandbox-1',
351+
})
352+
expect(request).not.toHaveProperty('sandboxKind')
353+
})
354+
355+
it('does not treat the Mothership base as a fallback for a selected Sim sandbox', async () => {
356+
envFlagsMock.isMothershipSandboxEnabled = true
357+
hybridAuthMockFns.mockCheckInternalAuth.mockResolvedValueOnce({
358+
success: true,
359+
userId: 'user-123',
360+
authType: 'internal_jwt',
361+
sandboxProfile: 'mothership',
362+
})
363+
364+
const response = await POST(
365+
createMockRequest('POST', {
366+
code: 'return 42',
367+
language: 'javascript',
368+
workspaceId: 'workspace-1',
369+
sandboxId: 'sandbox-1',
370+
})
371+
)
372+
373+
expect(response.status).toBe(503)
374+
await expect(response.json()).resolves.toMatchObject({
375+
error: 'The Function code sandbox is not configured',
376+
})
377+
expect(mockExecuteInSandbox).not.toHaveBeenCalled()
378+
expect(mockExecuteInIsolatedVM).not.toHaveBeenCalled()
379+
})
380+
381+
it('forces import-free JavaScript into the remote runtime when a Sim sandbox is selected', async () => {
382+
envFlagsMock.isRemoteSandboxEnabled = true
383+
384+
const response = await POST(
385+
createMockRequest('POST', {
386+
code: 'return 42',
387+
language: 'javascript',
388+
workspaceId: 'workspace-1',
389+
sandboxId: 'sandbox-1',
390+
})
391+
)
392+
393+
expect(response.status).toBe(200)
394+
expect(mockExecuteInSandbox).toHaveBeenCalledWith(
395+
expect.objectContaining({
396+
language: 'javascript',
397+
workspaceId: 'workspace-1',
398+
sandboxId: 'sandbox-1',
399+
})
400+
)
401+
expect(mockExecuteInIsolatedVM).not.toHaveBeenCalled()
402+
})
403+
294404
it('should prevent VM escape via constructor chain', async () => {
295405
mockExecuteInIsolatedVM.mockResolvedValueOnce({ result: undefined, stdout: '' })
296406

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

Lines changed: 31 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1688,15 +1688,6 @@ export const POST = withRouteHandler(async (req: NextRequest) => {
16881688
return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 })
16891689
}
16901690
const usesMothershipSandbox = auth.sandboxProfile === 'mothership'
1691-
if (usesMothershipSandbox && !isMothershipSandboxEnabled) {
1692-
return NextResponse.json(
1693-
{ success: false, error: 'Mothership code sandbox is not configured' },
1694-
{ status: 503 }
1695-
)
1696-
}
1697-
const remoteSandboxEnabled = usesMothershipSandbox
1698-
? isMothershipSandboxEnabled
1699-
: isRemoteSandboxEnabled
17001691

17011692
executionDeadlineAt = parseExecutionDeadlineHeader(req.headers)
17021693
includePrivateResolvedSecretNames = requestsPrivateToolMetadata(
@@ -1746,6 +1737,26 @@ export const POST = withRouteHandler(async (req: NextRequest) => {
17461737
isCustomTool = false,
17471738
_sandboxFiles,
17481739
} = body
1740+
if (selectedSandboxId && !isRemoteSandboxEnabled) {
1741+
return NextResponse.json(
1742+
{ success: false, error: 'The Function code sandbox is not configured' },
1743+
{ status: 503 }
1744+
)
1745+
}
1746+
if (usesMothershipSandbox && !selectedSandboxId && !isMothershipSandboxEnabled) {
1747+
return NextResponse.json(
1748+
{ success: false, error: 'Mothership code sandbox is not configured' },
1749+
{ status: 503 }
1750+
)
1751+
}
1752+
// A selected Sim sandbox is layered on the Function base, even for a
1753+
// trusted Mothership call. Only an unselected Mothership call uses the
1754+
// separately built Mothership image.
1755+
const remoteSandboxEnabled = selectedSandboxId
1756+
? isRemoteSandboxEnabled
1757+
: usesMothershipSandbox
1758+
? isMothershipSandboxEnabled
1759+
: isRemoteSandboxEnabled
17491760
const remainingExecutionMs =
17501761
executionDeadlineAt === undefined ? undefined : Math.max(1, executionDeadlineAt - Date.now())
17511762
const timeout =
@@ -1934,7 +1945,9 @@ export const POST = withRouteHandler(async (req: NextRequest) => {
19341945
outputSandboxPaths,
19351946
workspaceId,
19361947
sandboxId: selectedSandboxId,
1937-
...(usesMothershipSandbox ? { sandboxKind: 'mothership' as const } : {}),
1948+
...(usesMothershipSandbox && !selectedSandboxId
1949+
? { sandboxKind: 'mothership' as const }
1950+
: {}),
19381951
signal: executionSignal,
19391952
})
19401953
const executionTime = Date.now() - execStart
@@ -1998,7 +2011,8 @@ export const POST = withRouteHandler(async (req: NextRequest) => {
19982011
usesMothershipSandbox ||
19992012
(remoteSandboxEnabled &&
20002013
!isCustomTool &&
2001-
(lang === CodeLanguage.Python || (lang === CodeLanguage.JavaScript && hasImports)))
2014+
(lang === CodeLanguage.Python ||
2015+
(lang === CodeLanguage.JavaScript && (hasImports || Boolean(selectedSandboxId)))))
20022016

20032017
if (useRemoteSandbox && containsLargeValueRef(contextVariables)) {
20042018
throw new Error(
@@ -2098,7 +2112,9 @@ export const POST = withRouteHandler(async (req: NextRequest) => {
20982112
outputSandboxPaths,
20992113
workspaceId,
21002114
sandboxId: selectedSandboxId,
2101-
...(usesMothershipSandbox ? { sandboxKind: 'mothership' as const } : {}),
2115+
...(usesMothershipSandbox && !selectedSandboxId
2116+
? { sandboxKind: 'mothership' as const }
2117+
: {}),
21022118
signal: executionSignal,
21032119
})
21042120
const executionTime = Date.now() - execStart
@@ -2185,7 +2201,9 @@ export const POST = withRouteHandler(async (req: NextRequest) => {
21852201
outputSandboxPaths,
21862202
workspaceId,
21872203
sandboxId: selectedSandboxId,
2188-
...(usesMothershipSandbox ? { sandboxKind: 'mothership' as const } : {}),
2204+
...(usesMothershipSandbox && !selectedSandboxId
2205+
? { sandboxKind: 'mothership' as const }
2206+
: {}),
21892207
signal: executionSignal,
21902208
})
21912209
const executionTime = Date.now() - execStart
Lines changed: 14 additions & 107 deletions
Original file line numberDiff line numberDiff line change
@@ -1,24 +1,15 @@
1-
import { db } from '@sim/db'
2-
import { workspaceSandbox } from '@sim/db/schema'
31
import { createLogger } from '@sim/logger'
4-
import { and, eq } from 'drizzle-orm'
52
import { type NextRequest, NextResponse } from 'next/server'
63
import { deleteSandboxContract, updateSandboxContract } from '@/lib/api/contracts/sandboxes'
74
import { parseRequest } from '@/lib/api/server'
8-
import { runDetached } from '@/lib/core/utils/background'
95
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
10-
import { releaseSandboxImage } from '@/lib/execution/remote-sandbox/image-registry'
11-
import { invalidateSandboxResolution } from '@/lib/execution/remote-sandbox/resolve'
126
import {
13-
isSandboxNameTaken,
14-
readWorkspaceSandbox,
15-
scheduleSandboxBuild,
7+
deleteWorkspaceSandbox,
8+
updateWorkspaceSandbox,
169
} from '@/lib/execution/remote-sandbox/workspace-sandboxes'
1710
import {
1811
authorizeSandboxMutation,
19-
buildSpecOrResponse,
20-
isNameConflictError,
21-
nameConflictResponse,
12+
sandboxMutationErrorResponse,
2213
} from '@/app/api/workspaces/[id]/sandboxes/authorize'
2314

2415
const logger = createLogger('WorkspaceSandboxAPI')
@@ -33,90 +24,15 @@ export const PATCH = withRouteHandler(async (request: NextRequest, context: Sand
3324

3425
const parsed = await parseRequest(updateSandboxContract, request, context)
3526
if (!parsed.success) return parsed.response
36-
const { name, language, dependencies, cliTools, systemPackages } = parsed.data.body
37-
38-
const [existing] = await db
39-
.select({
40-
id: workspaceSandbox.id,
41-
name: workspaceSandbox.name,
42-
language: workspaceSandbox.language,
43-
dependencies: workspaceSandbox.dependencies,
44-
cliTools: workspaceSandbox.cliTools,
45-
systemPackages: workspaceSandbox.systemPackages,
46-
specHash: workspaceSandbox.specHash,
47-
})
48-
.from(workspaceSandbox)
49-
.where(and(eq(workspaceSandbox.id, sandboxId), eq(workspaceSandbox.workspaceId, workspaceId)))
50-
.limit(1)
51-
52-
if (!existing) {
53-
return NextResponse.json({ error: 'Sandbox not found' }, { status: 404 })
54-
}
55-
56-
const nextName = name ?? existing.name
57-
if (name && name !== existing.name && (await isSandboxNameTaken(workspaceId, name, sandboxId))) {
58-
return nameConflictResponse(name)
59-
}
60-
61-
// The complete spec is revalidated even when only one field changed: switching
62-
// language has to re-check the existing list against the new language's rules,
63-
// and editing dependencies has to check them against the stored language.
64-
const nextLanguage = language ?? (existing.language as 'javascript' | 'python')
65-
const nextDependencies = dependencies ?? existing.dependencies ?? []
66-
const nextCliTools = cliTools ?? existing.cliTools ?? []
67-
const nextSystemPackages = systemPackages ?? existing.systemPackages ?? []
68-
69-
const built = buildSpecOrResponse(
70-
nextLanguage,
71-
nextDependencies,
72-
nextCliTools,
73-
nextSystemPackages
74-
)
75-
if (!built.ok) return built.response
76-
const { spec } = built
77-
7827
try {
79-
await db
80-
.update(workspaceSandbox)
81-
.set({
82-
name: nextName,
83-
language: spec.language,
84-
dependencies: spec.dependencies,
85-
cliTools: spec.cliTools,
86-
systemPackages: spec.systemPackages,
87-
specHash: spec.specHash,
88-
updatedAt: new Date(),
89-
})
90-
// Scoped by workspace as well as id: every other query here is, and relying on
91-
// the SELECT above to have 404'd first makes authz an ordering invariant.
92-
.where(and(eq(workspaceSandbox.id, sandboxId), eq(workspaceSandbox.workspaceId, workspaceId)))
28+
const sandbox = await updateWorkspaceSandbox(workspaceId, sandboxId, parsed.data.body)
29+
logger.info('Updated workspace sandbox', { workspaceId, sandboxId })
30+
return NextResponse.json({ sandbox })
9331
} catch (error) {
94-
// The pre-check above can lose a race with a concurrent rename; the unique
95-
// index is the real arbiter, and losing it is a conflict, not a server fault.
96-
if (isNameConflictError(error)) return nameConflictResponse(nextName)
32+
const response = sandboxMutationErrorResponse(error)
33+
if (response) return response
9734
throw error
9835
}
99-
100-
// Unconditional, because the registry decides what a save costs: a `ready` or
101-
// in-flight row is left alone, so renaming or re-saving an unchanged spec
102-
// enqueues nothing, while a failed one gets the immediate retry a person saving
103-
// is asking for. Gating this on a changed hash meant a same-spec save silently
104-
// did nothing, and the only way to retry a failed build was to edit the package
105-
// list into a different hash.
106-
await scheduleSandboxBuild(spec)
107-
108-
if (spec.specHash !== existing.specHash) {
109-
// The previous content address is unreferenced by this sandbox now. Release
110-
// no-ops when another sandbox still declares the same package list.
111-
runDetached('release-sandbox-image', () => releaseSandboxImage(existing.specHash))
112-
logger.info('Sandbox spec changed, scheduled a build', { workspaceId, sandboxId })
113-
}
114-
115-
const sandbox = await readWorkspaceSandbox(workspaceId, sandboxId)
116-
if (!sandbox) {
117-
return NextResponse.json({ error: 'Failed to read back the updated sandbox' }, { status: 500 })
118-
}
119-
return NextResponse.json({ sandbox })
12036
})
12137

12238
export const DELETE = withRouteHandler(async (request: NextRequest, context: SandboxContext) => {
@@ -128,23 +44,14 @@ export const DELETE = withRouteHandler(async (request: NextRequest, context: San
12844
const parsed = await parseRequest(deleteSandboxContract, request, context)
12945
if (!parsed.success) return parsed.response
13046

131-
// A block may still reference this sandbox. Deleting is allowed anyway; that
132-
// execution then fails closed with a message naming the missing sandbox,
133-
// rather than silently falling back to an image without its dependencies.
134-
const deleted = await db
135-
.delete(workspaceSandbox)
136-
.where(and(eq(workspaceSandbox.id, sandboxId), eq(workspaceSandbox.workspaceId, workspaceId)))
137-
.returning({ id: workspaceSandbox.id, specHash: workspaceSandbox.specHash })
138-
139-
if (deleted.length === 0) {
140-
return NextResponse.json({ error: 'Sandbox not found' }, { status: 404 })
47+
try {
48+
await deleteWorkspaceSandbox(workspaceId, sandboxId)
49+
} catch (error) {
50+
const response = sandboxMutationErrorResponse(error)
51+
if (response) return response
52+
throw error
14153
}
14254

143-
invalidateSandboxResolution()
144-
// Detached: the row is already gone, so the caller's delete succeeded whatever
145-
// the provider says. Awaiting would hold a UI delete open on a remote call the
146-
// retention sweep would retry anyway.
147-
runDetached('release-sandbox-image', () => releaseSandboxImage(deleted[0].specHash))
14855
logger.info('Deleted workspace sandbox', { workspaceId, sandboxId })
14956
return NextResponse.json({ success: true })
15057
})

0 commit comments

Comments
 (0)