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
116 changes: 14 additions & 102 deletions apps/sim/app/api/workspaces/[id]/sandboxes/[sandboxId]/route.ts
Original file line number Diff line number Diff line change
@@ -1,28 +1,16 @@
import { db } from '@sim/db'
import { workspaceSandbox } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { and, eq } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import { deleteSandboxContract, updateSandboxContract } from '@/lib/api/contracts/sandboxes'
import { parseRequest } from '@/lib/api/server'
import { runDetached } from '@/lib/core/utils/background'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import { releaseSandboxImage } from '@/lib/execution/remote-sandbox/image-registry'
import { invalidateSandboxResolution } from '@/lib/execution/remote-sandbox/resolve'
import {
isSandboxNameTaken,
readWorkspaceSandbox,
scheduleSandboxBuild,
deleteWorkspaceSandbox,
updateWorkspaceSandbox,
} from '@/lib/execution/remote-sandbox/workspace-sandboxes'
import {
authorizeSandboxMutation,
buildSpecOrResponse,
isNameConflictError,
nameConflictResponse,
sandboxFailureResponse,
} from '@/app/api/workspaces/[id]/sandboxes/authorize'

const logger = createLogger('WorkspaceSandboxAPI')

type SandboxContext = { params: Promise<{ id: string; sandboxId: string }> }

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

const [existing] = await db
.select({
id: workspaceSandbox.id,
name: workspaceSandbox.name,
language: workspaceSandbox.language,
dependencies: workspaceSandbox.dependencies,
specHash: workspaceSandbox.specHash,
})
.from(workspaceSandbox)
.where(and(eq(workspaceSandbox.id, sandboxId), eq(workspaceSandbox.workspaceId, workspaceId)))
.limit(1)

if (!existing) {
return NextResponse.json({ error: 'Sandbox not found' }, { status: 404 })
}

const nextName = name ?? existing.name
if (name && name !== existing.name && (await isSandboxNameTaken(workspaceId, name, sandboxId))) {
return nameConflictResponse(name)
}

// Both halves are revalidated together even when only one changed: switching
// language has to re-check the existing list against the new language's rules,
// and editing dependencies has to check them against the stored language.
const nextLanguage = language ?? (existing.language as 'javascript' | 'python')
const nextDependencies = dependencies ?? existing.dependencies ?? []

const built = buildSpecOrResponse(nextLanguage, nextDependencies)
if (!built.ok) return built.response
const { spec } = built
const result = await updateWorkspaceSandbox({
workspaceId,
sandboxId,
name,
language,
dependencies,
})
if (!result.ok) return sandboxFailureResponse(result.failure)

try {
await db
.update(workspaceSandbox)
.set({
name: nextName,
language: spec.language,
dependencies: spec.dependencies,
specHash: spec.specHash,
updatedAt: new Date(),
})
// Scoped by workspace as well as id: every other query here is, and relying on
// the SELECT above to have 404'd first makes authz an ordering invariant.
.where(and(eq(workspaceSandbox.id, sandboxId), eq(workspaceSandbox.workspaceId, workspaceId)))
} catch (error) {
// The pre-check above can lose a race with a concurrent rename; the unique
// index is the real arbiter, and losing it is a conflict, not a server fault.
if (isNameConflictError(error)) return nameConflictResponse(nextName)
throw error
}

// Unconditional, because the registry decides what a save costs: a `ready` or
// in-flight row is left alone, so renaming or re-saving an unchanged spec
// enqueues nothing, while a failed one gets the immediate retry a person saving
// is asking for. Gating this on a changed hash meant a same-spec save silently
// did nothing, and the only way to retry a failed build was to edit the package
// list into a different hash.
await scheduleSandboxBuild(spec)

if (spec.specHash !== existing.specHash) {
// The previous content address is unreferenced by this sandbox now. Release
// no-ops when another sandbox still declares the same package list.
runDetached('release-sandbox-image', () => releaseSandboxImage(existing.specHash))
logger.info('Sandbox spec changed, scheduled a build', { workspaceId, sandboxId })
}

const sandbox = await readWorkspaceSandbox(workspaceId, sandboxId)
if (!sandbox) {
return NextResponse.json({ error: 'Failed to read back the updated sandbox' }, { status: 500 })
}
return NextResponse.json({ sandbox })
return NextResponse.json({ sandbox: result.sandbox })
})

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

// A block may still reference this sandbox. Deleting is allowed anyway; that
// execution then fails closed with a message naming the missing sandbox,
// rather than silently falling back to an image without its dependencies.
const deleted = await db
.delete(workspaceSandbox)
.where(and(eq(workspaceSandbox.id, sandboxId), eq(workspaceSandbox.workspaceId, workspaceId)))
.returning({ id: workspaceSandbox.id, specHash: workspaceSandbox.specHash })

if (deleted.length === 0) {
return NextResponse.json({ error: 'Sandbox not found' }, { status: 404 })
}
const result = await deleteWorkspaceSandbox(workspaceId, sandboxId)
if (!result.ok) return sandboxFailureResponse(result.failure)

invalidateSandboxResolution()
// Detached: the row is already gone, so the caller's delete succeeded whatever
// the provider says. Awaiting would hold a UI delete open on a remote call the
// retention sweep would retry anyway.
runDetached('release-sandbox-image', () => releaseSandboxImage(deleted[0].specHash))
logger.info('Deleted workspace sandbox', { workspaceId, sandboxId })
return NextResponse.json({ success: true })
})
81 changes: 23 additions & 58 deletions apps/sim/app/api/workspaces/[id]/sandboxes/authorize.ts
Original file line number Diff line number Diff line change
@@ -1,17 +1,12 @@
import { getErrorMessage } from '@sim/utils/errors'
import { type NextRequest, NextResponse } from 'next/server'
import { NextResponse } from 'next/server'
import { getSession } from '@/lib/auth'
import { hasWorkspaceSandboxAccess } from '@/lib/billing/core/subscription'
import { enforceWorkspaceRateLimit } from '@/lib/core/rate-limiter/route-helpers'
import type { SandboxLanguage } from '@/lib/execution/remote-sandbox/sandbox-spec'
import {
buildSpecUpdate,
MAX_PLAN_REQUIRED,
SANDBOX_ADMIN_REQUIRED,
SANDBOX_MUTATION_LIMIT,
SandboxDependencyError,
type SandboxSpecUpdate,
WORKSPACE_SANDBOX_NAME_INDEX,
type SandboxWriteFailure,
} from '@/lib/execution/remote-sandbox/workspace-sandboxes'
import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils'

Expand All @@ -22,57 +17,30 @@ export interface SandboxMutationActor {
}

/**
* The 409 both write paths return for a duplicate name. Shared so the pre-check
* and the unique-index catch cannot describe the same conflict differently.
* `invalid_dependencies` carries a line number per rejected row, which the
* generic validation error does not — the editor marks those inline.
*/
export function nameConflictResponse(name: string): NextResponse {
return NextResponse.json(
{ error: `A sandbox named "${name}" already exists in this workspace` },
{ status: 409 }
)
}

/**
* Validates a submitted dependency list, returning the 400 the editor knows how
* to read — `issues` carries a line number per rejected row, which the generic
* validation error does not.
*/
export function buildSpecOrResponse(
language: SandboxLanguage,
dependencies: readonly string[]
): { ok: true; spec: SandboxSpecUpdate } | { ok: false; response: NextResponse } {
try {
return { ok: true, spec: buildSpecUpdate(language, dependencies) }
} catch (error) {
if (error instanceof SandboxDependencyError) {
return {
ok: false,
response: NextResponse.json(
{ error: error.message, issues: error.issues },
{ status: 400 }
),
}
}
throw error
export function sandboxFailureResponse(failure: SandboxWriteFailure): NextResponse {
switch (failure.code) {
case 'invalid_name':
return NextResponse.json({ error: failure.message }, { status: 400 })
case 'name_conflict':
return NextResponse.json(
{ error: `A sandbox named "${failure.name}" already exists in this workspace` },
{ status: 409 }
)
case 'invalid_dependencies':
return NextResponse.json({ error: failure.message, issues: failure.issues }, { status: 400 })
case 'not_found':
return NextResponse.json({ error: 'Sandbox not found' }, { status: 404 })
case 'read_back_failed':
return NextResponse.json({ error: 'Failed to read back the saved sandbox' }, { status: 500 })
}
}

/**
* Whether a write failed because it collided with the workspace/name unique
* index. Both paths pre-check the name, but the index is the real arbiter and a
* concurrent write can still lose the race — which is a 409, not a 500.
*/
export function isNameConflictError(error: unknown): boolean {
const message = getErrorMessage(error)
return message.includes(WORKSPACE_SANDBOX_NAME_INDEX) || message.includes('23505')
}

/**
* Authenticates, authorizes, entitles, and rate-limits a sandbox mutation — in
* that order, and always before any untrusted input is parsed.
*
* Shared by both route files so the create path and the edit/delete path cannot
* drift into different checks.
*/
export async function authorizeSandboxMutation(
workspaceId: string
Expand Down Expand Up @@ -108,17 +76,14 @@ export async function authorizeSandboxMutation(
}

/** Reads a workspace sandbox list; any member may look, only admins may write. */
export async function authorizeSandboxRead(
_request: NextRequest,
workspaceId: string
): Promise<{ ok: true; userId: string } | { ok: false; response: NextResponse }> {
export async function authorizeSandboxRead(workspaceId: string): Promise<NextResponse | null> {
const session = await getSession()
if (!session?.user?.id) {
return { ok: false, response: NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) }
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const permission = await getUserEntityPermissions(session.user.id, 'workspace', workspaceId)
if (!permission) {
return { ok: false, response: NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) }
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
return { ok: true, userId: session.user.id }
return null
}
65 changes: 13 additions & 52 deletions apps/sim/app/api/workspaces/[id]/sandboxes/route.ts
Original file line number Diff line number Diff line change
@@ -1,36 +1,25 @@
import { db } from '@sim/db'
import { workspaceSandbox } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { getErrorMessage } from '@sim/utils/errors'
import { generateId } from '@sim/utils/id'
import { type NextRequest, NextResponse } from 'next/server'
import { createSandboxContract } from '@/lib/api/contracts/sandboxes'
import { parseRequest } from '@/lib/api/server'
import { hasWorkspaceSandboxAccess } from '@/lib/billing/core/subscription'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import {
createWorkspaceSandbox,
currentSandboxStrategy,
isSandboxNameTaken,
listWorkspaceSandboxes,
readWorkspaceSandbox,
scheduleSandboxBuild,
} from '@/lib/execution/remote-sandbox/workspace-sandboxes'
import {
authorizeSandboxMutation,
authorizeSandboxRead,
buildSpecOrResponse,
isNameConflictError,
nameConflictResponse,
sandboxFailureResponse,
} from '@/app/api/workspaces/[id]/sandboxes/authorize'

const logger = createLogger('WorkspaceSandboxesAPI')

export const GET = withRouteHandler(
async (request: NextRequest, context: { params: Promise<{ id: string }> }) => {
const workspaceId = (await context.params).id

const viewer = await authorizeSandboxRead(request, workspaceId)
if (!viewer.ok) return viewer.response
const denied = await authorizeSandboxRead(workspaceId)
if (denied) return denied

// The list itself is not plan-gated: a workspace that downgraded must still
// see (and keep executing) what it already built. `entitled` drives whether
Expand Down Expand Up @@ -59,43 +48,15 @@ export const POST = withRouteHandler(
if (!parsed.success) return parsed.response
const { name, language, dependencies } = parsed.data.body

const built = buildSpecOrResponse(language, dependencies)
if (!built.ok) return built.response
const { spec } = built

if (await isSandboxNameTaken(workspaceId, name)) {
return nameConflictResponse(name)
}

const id = generateId()
try {
await db.insert(workspaceSandbox).values({
id,
workspaceId,
name,
language: spec.language,
dependencies: spec.dependencies,
specHash: spec.specHash,
createdBy: authorized.actor.userId,
})
} catch (error) {
// The unique index is the real arbiter — the pre-check above only exists to
// return a friendlier message when there is no race.
if (isNameConflictError(error)) return nameConflictResponse(name)
logger.error('Failed to insert sandbox', { workspaceId, error: getErrorMessage(error) })
throw error
}

await scheduleSandboxBuild(spec)
logger.info('Created workspace sandbox', { workspaceId, sandboxId: id, language })
const result = await createWorkspaceSandbox({
workspaceId,
userId: authorized.actor.userId,
name,
language,
dependencies,
})
if (!result.ok) return sandboxFailureResponse(result.failure)

const sandbox = await readWorkspaceSandbox(workspaceId, id)
if (!sandbox) {
return NextResponse.json(
{ error: 'Failed to read back the created sandbox' },
{ status: 500 }
)
}
return NextResponse.json({ sandbox })
return NextResponse.json({ sandbox: result.sandbox })
}
)
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ const TOOL_ICONS: Record<string, IconComponent> = {
get_page_contents: Search,
search_library_docs: Library,
manage_mcp_tool: Settings,
manage_sandbox: TerminalWindow,
manage_skill: Asterisk,
user_memory: Database,
function_execute: TerminalWindow,
Expand Down
2 changes: 1 addition & 1 deletion apps/sim/lib/api/contracts/sandboxes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ const dependencyListSchema = z
.array(z.string().max(2000, 'a dependency line is unreasonably long'))
.max(1000, 'too many lines — paste a shorter dependency list')

const sandboxNameSchema = z
export const sandboxNameSchema = z
.string()
.trim()
.min(1, 'Name is required')
Expand Down
Loading
Loading