Skip to content

Commit e5af15d

Browse files
committed
feat(copilot): let the mothership manage workspace sandboxes
Adds the manage_sandbox handler (add/edit/delete/list), following the manage_custom_tool precedent, plus the display title and generated catalog bindings. Sandbox create/update/delete moves out of the two REST route files into workspace-sandboxes.ts as createWorkspaceSandbox/updateWorkspaceSandbox/ deleteWorkspaceSandbox, returning a typed failure the caller renders for its own surface: the routes map it to 409/400/404, the tool to a sentence. The routes previously owned the name-conflict pre-check, the unique-index race catch, the unconditional build enqueue, and the detached image release; sharing them is what keeps the tool from drifting from the UI. The handler reproduces the routes' gate exactly — workspace admin, then the Max/Enterprise entitlement, then the same per-workspace mutation bucket — so a sandbox cannot be created through chat that the same user could not create in Settings > Sandboxes. `list` needs only read access, matching GET, because a downgraded workspace must still see what it built. workspaceId comes from server context only.
1 parent 856fe0f commit e5af15d

10 files changed

Lines changed: 890 additions & 214 deletions

File tree

Lines changed: 14 additions & 102 deletions
Original file line numberDiff line numberDiff line change
@@ -1,28 +1,16 @@
1-
import { db } from '@sim/db'
2-
import { workspaceSandbox } from '@sim/db/schema'
3-
import { createLogger } from '@sim/logger'
4-
import { and, eq } from 'drizzle-orm'
51
import { type NextRequest, NextResponse } from 'next/server'
62
import { deleteSandboxContract, updateSandboxContract } from '@/lib/api/contracts/sandboxes'
73
import { parseRequest } from '@/lib/api/server'
8-
import { runDetached } from '@/lib/core/utils/background'
94
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'
125
import {
13-
isSandboxNameTaken,
14-
readWorkspaceSandbox,
15-
scheduleSandboxBuild,
6+
deleteWorkspaceSandbox,
7+
updateWorkspaceSandbox,
168
} from '@/lib/execution/remote-sandbox/workspace-sandboxes'
179
import {
1810
authorizeSandboxMutation,
19-
buildSpecOrResponse,
20-
isNameConflictError,
21-
nameConflictResponse,
11+
sandboxFailureResponse,
2212
} from '@/app/api/workspaces/[id]/sandboxes/authorize'
2313

24-
const logger = createLogger('WorkspaceSandboxAPI')
25-
2614
type SandboxContext = { params: Promise<{ id: string; sandboxId: string }> }
2715

2816
export const PATCH = withRouteHandler(async (request: NextRequest, context: SandboxContext) => {
@@ -35,77 +23,16 @@ export const PATCH = withRouteHandler(async (request: NextRequest, context: Sand
3523
if (!parsed.success) return parsed.response
3624
const { name, language, dependencies } = parsed.data.body
3725

38-
const [existing] = await db
39-
.select({
40-
id: workspaceSandbox.id,
41-
name: workspaceSandbox.name,
42-
language: workspaceSandbox.language,
43-
dependencies: workspaceSandbox.dependencies,
44-
specHash: workspaceSandbox.specHash,
45-
})
46-
.from(workspaceSandbox)
47-
.where(and(eq(workspaceSandbox.id, sandboxId), eq(workspaceSandbox.workspaceId, workspaceId)))
48-
.limit(1)
49-
50-
if (!existing) {
51-
return NextResponse.json({ error: 'Sandbox not found' }, { status: 404 })
52-
}
53-
54-
const nextName = name ?? existing.name
55-
if (name && name !== existing.name && (await isSandboxNameTaken(workspaceId, name, sandboxId))) {
56-
return nameConflictResponse(name)
57-
}
58-
59-
// Both halves are revalidated together even when only one changed: switching
60-
// language has to re-check the existing list against the new language's rules,
61-
// and editing dependencies has to check them against the stored language.
62-
const nextLanguage = language ?? (existing.language as 'javascript' | 'python')
63-
const nextDependencies = dependencies ?? existing.dependencies ?? []
64-
65-
const built = buildSpecOrResponse(nextLanguage, nextDependencies)
66-
if (!built.ok) return built.response
67-
const { spec } = built
26+
const result = await updateWorkspaceSandbox({
27+
workspaceId,
28+
sandboxId,
29+
name,
30+
language,
31+
dependencies,
32+
})
33+
if (!result.ok) return sandboxFailureResponse(result.failure)
6834

69-
try {
70-
await db
71-
.update(workspaceSandbox)
72-
.set({
73-
name: nextName,
74-
language: spec.language,
75-
dependencies: spec.dependencies,
76-
specHash: spec.specHash,
77-
updatedAt: new Date(),
78-
})
79-
// Scoped by workspace as well as id: every other query here is, and relying on
80-
// the SELECT above to have 404'd first makes authz an ordering invariant.
81-
.where(and(eq(workspaceSandbox.id, sandboxId), eq(workspaceSandbox.workspaceId, workspaceId)))
82-
} catch (error) {
83-
// The pre-check above can lose a race with a concurrent rename; the unique
84-
// index is the real arbiter, and losing it is a conflict, not a server fault.
85-
if (isNameConflictError(error)) return nameConflictResponse(nextName)
86-
throw error
87-
}
88-
89-
// Unconditional, because the registry decides what a save costs: a `ready` or
90-
// in-flight row is left alone, so renaming or re-saving an unchanged spec
91-
// enqueues nothing, while a failed one gets the immediate retry a person saving
92-
// is asking for. Gating this on a changed hash meant a same-spec save silently
93-
// did nothing, and the only way to retry a failed build was to edit the package
94-
// list into a different hash.
95-
await scheduleSandboxBuild(spec)
96-
97-
if (spec.specHash !== existing.specHash) {
98-
// The previous content address is unreferenced by this sandbox now. Release
99-
// no-ops when another sandbox still declares the same package list.
100-
runDetached('release-sandbox-image', () => releaseSandboxImage(existing.specHash))
101-
logger.info('Sandbox spec changed, scheduled a build', { workspaceId, sandboxId })
102-
}
103-
104-
const sandbox = await readWorkspaceSandbox(workspaceId, sandboxId)
105-
if (!sandbox) {
106-
return NextResponse.json({ error: 'Failed to read back the updated sandbox' }, { status: 500 })
107-
}
108-
return NextResponse.json({ sandbox })
35+
return NextResponse.json({ sandbox: result.sandbox })
10936
})
11037

11138
export const DELETE = withRouteHandler(async (request: NextRequest, context: SandboxContext) => {
@@ -117,23 +44,8 @@ export const DELETE = withRouteHandler(async (request: NextRequest, context: San
11744
const parsed = await parseRequest(deleteSandboxContract, request, context)
11845
if (!parsed.success) return parsed.response
11946

120-
// A block may still reference this sandbox. Deleting is allowed anyway; that
121-
// execution then fails closed with a message naming the missing sandbox,
122-
// rather than silently falling back to an image without its dependencies.
123-
const deleted = await db
124-
.delete(workspaceSandbox)
125-
.where(and(eq(workspaceSandbox.id, sandboxId), eq(workspaceSandbox.workspaceId, workspaceId)))
126-
.returning({ id: workspaceSandbox.id, specHash: workspaceSandbox.specHash })
127-
128-
if (deleted.length === 0) {
129-
return NextResponse.json({ error: 'Sandbox not found' }, { status: 404 })
130-
}
47+
const result = await deleteWorkspaceSandbox(workspaceId, sandboxId)
48+
if (!result.ok) return sandboxFailureResponse(result.failure)
13149

132-
invalidateSandboxResolution()
133-
// Detached: the row is already gone, so the caller's delete succeeded whatever
134-
// the provider says. Awaiting would hold a UI delete open on a remote call the
135-
// retention sweep would retry anyway.
136-
runDetached('release-sandbox-image', () => releaseSandboxImage(deleted[0].specHash))
137-
logger.info('Deleted workspace sandbox', { workspaceId, sandboxId })
13850
return NextResponse.json({ success: true })
13951
})

apps/sim/app/api/workspaces/[id]/sandboxes/authorize.ts

Lines changed: 21 additions & 52 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,12 @@
1-
import { getErrorMessage } from '@sim/utils/errors'
2-
import { type NextRequest, NextResponse } from 'next/server'
1+
import { NextResponse } from 'next/server'
32
import { getSession } from '@/lib/auth'
43
import { hasWorkspaceSandboxAccess } from '@/lib/billing/core/subscription'
54
import { enforceWorkspaceRateLimit } from '@/lib/core/rate-limiter/route-helpers'
6-
import type { SandboxLanguage } from '@/lib/execution/remote-sandbox/sandbox-spec'
75
import {
8-
buildSpecUpdate,
96
MAX_PLAN_REQUIRED,
107
SANDBOX_ADMIN_REQUIRED,
118
SANDBOX_MUTATION_LIMIT,
12-
SandboxDependencyError,
13-
type SandboxSpecUpdate,
14-
WORKSPACE_SANDBOX_NAME_INDEX,
9+
type SandboxWriteFailure,
1510
} from '@/lib/execution/remote-sandbox/workspace-sandboxes'
1611
import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils'
1712

@@ -22,57 +17,32 @@ export interface SandboxMutationActor {
2217
}
2318

2419
/**
25-
* The 409 both write paths return for a duplicate name. Shared so the pre-check
26-
* and the unique-index catch cannot describe the same conflict differently.
27-
*/
28-
export function nameConflictResponse(name: string): NextResponse {
29-
return NextResponse.json(
30-
{ error: `A sandbox named "${name}" already exists in this workspace` },
31-
{ status: 409 }
32-
)
33-
}
34-
35-
/**
36-
* Validates a submitted dependency list, returning the 400 the editor knows how
37-
* to read — `issues` carries a line number per rejected row, which the generic
38-
* validation error does not.
20+
* Maps a refused write onto the status code the editor expects. Shared by both
21+
* route files so the create path and the edit/delete path cannot describe the
22+
* same failure differently.
23+
*
24+
* `invalid_dependencies` carries a line number per rejected row, which the
25+
* generic validation error does not — the editor marks those inline.
3926
*/
40-
export function buildSpecOrResponse(
41-
language: SandboxLanguage,
42-
dependencies: readonly string[]
43-
): { ok: true; spec: SandboxSpecUpdate } | { ok: false; response: NextResponse } {
44-
try {
45-
return { ok: true, spec: buildSpecUpdate(language, dependencies) }
46-
} catch (error) {
47-
if (error instanceof SandboxDependencyError) {
48-
return {
49-
ok: false,
50-
response: NextResponse.json(
51-
{ error: error.message, issues: error.issues },
52-
{ status: 400 }
53-
),
54-
}
55-
}
56-
throw error
27+
export function sandboxFailureResponse(failure: SandboxWriteFailure): NextResponse {
28+
switch (failure.code) {
29+
case 'name_conflict':
30+
return NextResponse.json(
31+
{ error: `A sandbox named "${failure.name}" already exists in this workspace` },
32+
{ status: 409 }
33+
)
34+
case 'invalid_dependencies':
35+
return NextResponse.json({ error: failure.message, issues: failure.issues }, { status: 400 })
36+
case 'not_found':
37+
return NextResponse.json({ error: 'Sandbox not found' }, { status: 404 })
38+
case 'read_back_failed':
39+
return NextResponse.json({ error: 'Failed to read back the saved sandbox' }, { status: 500 })
5740
}
5841
}
5942

60-
/**
61-
* Whether a write failed because it collided with the workspace/name unique
62-
* index. Both paths pre-check the name, but the index is the real arbiter and a
63-
* concurrent write can still lose the race — which is a 409, not a 500.
64-
*/
65-
export function isNameConflictError(error: unknown): boolean {
66-
const message = getErrorMessage(error)
67-
return message.includes(WORKSPACE_SANDBOX_NAME_INDEX) || message.includes('23505')
68-
}
69-
7043
/**
7144
* Authenticates, authorizes, entitles, and rate-limits a sandbox mutation — in
7245
* that order, and always before any untrusted input is parsed.
73-
*
74-
* Shared by both route files so the create path and the edit/delete path cannot
75-
* drift into different checks.
7646
*/
7747
export async function authorizeSandboxMutation(
7848
workspaceId: string
@@ -109,7 +79,6 @@ export async function authorizeSandboxMutation(
10979

11080
/** Reads a workspace sandbox list; any member may look, only admins may write. */
11181
export async function authorizeSandboxRead(
112-
_request: NextRequest,
11382
workspaceId: string
11483
): Promise<{ ok: true; userId: string } | { ok: false; response: NextResponse }> {
11584
const session = await getSession()
Lines changed: 17 additions & 48 deletions
Original file line numberDiff line numberDiff line change
@@ -1,26 +1,18 @@
1-
import { db } from '@sim/db'
2-
import { workspaceSandbox } from '@sim/db/schema'
31
import { createLogger } from '@sim/logger'
4-
import { getErrorMessage } from '@sim/utils/errors'
5-
import { generateId } from '@sim/utils/id'
62
import { type NextRequest, NextResponse } from 'next/server'
73
import { createSandboxContract } from '@/lib/api/contracts/sandboxes'
84
import { parseRequest } from '@/lib/api/server'
95
import { hasWorkspaceSandboxAccess } from '@/lib/billing/core/subscription'
106
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
117
import {
8+
createWorkspaceSandbox,
129
currentSandboxStrategy,
13-
isSandboxNameTaken,
1410
listWorkspaceSandboxes,
15-
readWorkspaceSandbox,
16-
scheduleSandboxBuild,
1711
} from '@/lib/execution/remote-sandbox/workspace-sandboxes'
1812
import {
1913
authorizeSandboxMutation,
2014
authorizeSandboxRead,
21-
buildSpecOrResponse,
22-
isNameConflictError,
23-
nameConflictResponse,
15+
sandboxFailureResponse,
2416
} from '@/app/api/workspaces/[id]/sandboxes/authorize'
2517

2618
const logger = createLogger('WorkspaceSandboxesAPI')
@@ -29,7 +21,7 @@ export const GET = withRouteHandler(
2921
async (request: NextRequest, context: { params: Promise<{ id: string }> }) => {
3022
const workspaceId = (await context.params).id
3123

32-
const viewer = await authorizeSandboxRead(request, workspaceId)
24+
const viewer = await authorizeSandboxRead(workspaceId)
3325
if (!viewer.ok) return viewer.response
3426

3527
// The list itself is not plan-gated: a workspace that downgraded must still
@@ -59,43 +51,20 @@ export const POST = withRouteHandler(
5951
if (!parsed.success) return parsed.response
6052
const { name, language, dependencies } = parsed.data.body
6153

62-
const built = buildSpecOrResponse(language, dependencies)
63-
if (!built.ok) return built.response
64-
const { spec } = built
65-
66-
if (await isSandboxNameTaken(workspaceId, name)) {
67-
return nameConflictResponse(name)
68-
}
69-
70-
const id = generateId()
71-
try {
72-
await db.insert(workspaceSandbox).values({
73-
id,
74-
workspaceId,
75-
name,
76-
language: spec.language,
77-
dependencies: spec.dependencies,
78-
specHash: spec.specHash,
79-
createdBy: authorized.actor.userId,
80-
})
81-
} catch (error) {
82-
// The unique index is the real arbiter — the pre-check above only exists to
83-
// return a friendlier message when there is no race.
84-
if (isNameConflictError(error)) return nameConflictResponse(name)
85-
logger.error('Failed to insert sandbox', { workspaceId, error: getErrorMessage(error) })
86-
throw error
87-
}
88-
89-
await scheduleSandboxBuild(spec)
90-
logger.info('Created workspace sandbox', { workspaceId, sandboxId: id, language })
54+
const result = await createWorkspaceSandbox({
55+
workspaceId,
56+
userId: authorized.actor.userId,
57+
name,
58+
language,
59+
dependencies,
60+
})
61+
if (!result.ok) return sandboxFailureResponse(result.failure)
9162

92-
const sandbox = await readWorkspaceSandbox(workspaceId, id)
93-
if (!sandbox) {
94-
return NextResponse.json(
95-
{ error: 'Failed to read back the created sandbox' },
96-
{ status: 500 }
97-
)
98-
}
99-
return NextResponse.json({ sandbox })
63+
logger.info('Created workspace sandbox', {
64+
workspaceId,
65+
sandboxId: result.sandbox.id,
66+
language,
67+
})
68+
return NextResponse.json({ sandbox: result.sandbox })
10069
}
10170
)

0 commit comments

Comments
 (0)