diff --git a/apps/sim/app/api/workspaces/[id]/sandboxes/[sandboxId]/route.ts b/apps/sim/app/api/workspaces/[id]/sandboxes/[sandboxId]/route.ts index 98bdce0b87c..72cf8a1734b 100644 --- a/apps/sim/app/api/workspaces/[id]/sandboxes/[sandboxId]/route.ts +++ b/apps/sim/app/api/workspaces/[id]/sandboxes/[sandboxId]/route.ts @@ -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) => { @@ -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) => { @@ -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 }) }) diff --git a/apps/sim/app/api/workspaces/[id]/sandboxes/authorize.ts b/apps/sim/app/api/workspaces/[id]/sandboxes/authorize.ts index 7cfef4bea23..59492474218 100644 --- a/apps/sim/app/api/workspaces/[id]/sandboxes/authorize.ts +++ b/apps/sim/app/api/workspaces/[id]/sandboxes/authorize.ts @@ -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' @@ -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 @@ -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 { 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 } diff --git a/apps/sim/app/api/workspaces/[id]/sandboxes/route.ts b/apps/sim/app/api/workspaces/[id]/sandboxes/route.ts index f39bc6b4215..80e78d428ae 100644 --- a/apps/sim/app/api/workspaces/[id]/sandboxes/route.ts +++ b/apps/sim/app/api/workspaces/[id]/sandboxes/route.ts @@ -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 @@ -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 }) } ) diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/utils.ts b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/utils.ts index bae5654b19e..98b174116d5 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/utils.ts +++ b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/utils.ts @@ -39,6 +39,7 @@ const TOOL_ICONS: Record = { get_page_contents: Search, search_library_docs: Library, manage_mcp_tool: Settings, + manage_sandbox: TerminalWindow, manage_skill: Asterisk, user_memory: Database, function_execute: TerminalWindow, diff --git a/apps/sim/lib/api/contracts/sandboxes.ts b/apps/sim/lib/api/contracts/sandboxes.ts index 39547edfdc7..ff43161d3ab 100644 --- a/apps/sim/lib/api/contracts/sandboxes.ts +++ b/apps/sim/lib/api/contracts/sandboxes.ts @@ -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') diff --git a/apps/sim/lib/copilot/generated/tool-catalog-v1.ts b/apps/sim/lib/copilot/generated/tool-catalog-v1.ts index 8200e4f02d1..540296b602c 100644 --- a/apps/sim/lib/copilot/generated/tool-catalog-v1.ts +++ b/apps/sim/lib/copilot/generated/tool-catalog-v1.ts @@ -79,6 +79,7 @@ export interface ToolCatalogEntry { | 'manage_credential' | 'manage_custom_tool' | 'manage_mcp_tool' + | 'manage_sandbox' | 'manage_scheduled_task' | 'manage_skill' | 'materialize_file' @@ -199,6 +200,7 @@ export interface ToolCatalogEntry { | 'manage_credential' | 'manage_custom_tool' | 'manage_mcp_tool' + | 'manage_sandbox' | 'manage_scheduled_task' | 'manage_skill' | 'materialize_file' @@ -2314,7 +2316,7 @@ export const FunctionExecute: ToolCatalogEntry = { code: { type: 'string', description: - 'Code to execute. For JS: raw statements auto-wrapped in async context. For Python: full script. For shell: bash script with pre-installed CLI tools. Request each needed secret with an explicit {{VAR_NAME}} reference.', + 'Code to execute. For JS: raw statements auto-wrapped in async context. For Python: full script. For shell: bash script with pre-installed CLI tools. Use each needed secret as {{VAR_NAME}}; the reference resolves to the value exactly as stored.', }, inputs: { type: 'object', @@ -3605,6 +3607,48 @@ export const ManageMcpTool: ToolCatalogEntry = { requiredPermission: 'write', } +export const ManageSandbox: ToolCatalogEntry = { + id: 'manage_sandbox', + name: 'manage_sandbox', + route: 'sim', + mode: 'async', + parameters: { + type: 'object', + properties: { + dependencies: { + type: 'array', + description: + 'The COMPLETE package list, one package per entry — this REPLACES the existing list, it does not append. To add a package, call list first and resend the existing entries plus the new one. Optional for add (defaults to empty) and for edit.', + items: { type: 'string' }, + }, + language: { + type: 'string', + description: + "The sandbox's runtime. Required for add; on edit it re-validates the whole dependency list against the new language, so never switch language while leaving packages from the other ecosystem in place.", + enum: ['javascript', 'python'], + }, + name: { + type: 'string', + description: + 'Sandbox display name, unique within the workspace (max 64 characters). Required for add, optional for edit.', + }, + operation: { + type: 'string', + description: + "The operation to perform: 'add', 'edit', 'list', or 'delete'. These verbs are tool-specific — manage_scheduled_task uses create/update instead of add/edit.", + enum: ['add', 'edit', 'delete', 'list'], + }, + sandboxId: { + type: 'string', + description: + "The sandbox's id, from the `list` operation. Do not guess or construct it. Required for edit and delete; omit for add and list.", + }, + }, + required: ['operation'], + }, + requiredPermission: 'write', +} + export const ManageScheduledTask: ToolCatalogEntry = { id: 'manage_scheduled_task', name: 'manage_scheduled_task', @@ -4318,7 +4362,7 @@ export const RunCode: ToolCatalogEntry = { code: { type: 'string', description: - 'Code to execute. For JS: raw statements auto-wrapped in async context. For Python: full script. For shell: bash script with pre-installed CLI tools. Request each needed secret with an explicit {{VAR_NAME}} reference.', + 'Code to execute. For JS: raw statements auto-wrapped in async context. For Python: full script. For shell: bash script with pre-installed CLI tools. Use each needed secret as {{VAR_NAME}}; the reference resolves to the value exactly as stored.', }, inputs: { type: 'object', @@ -5764,6 +5808,23 @@ export const ManageMcpToolOperationValues = [ ManageMcpToolOperation.list, ] as const +export const ManageSandboxOperation = { + add: 'add', + edit: 'edit', + delete: 'delete', + list: 'list', +} as const + +export type ManageSandboxOperation = + (typeof ManageSandboxOperation)[keyof typeof ManageSandboxOperation] + +export const ManageSandboxOperationValues = [ + ManageSandboxOperation.add, + ManageSandboxOperation.edit, + ManageSandboxOperation.delete, + ManageSandboxOperation.list, +] as const + export const ManageScheduledTaskOperation = { create: 'create', list: 'list', @@ -6032,6 +6093,7 @@ export const TOOL_CATALOG: Record = { [ManageCredential.id]: ManageCredential, [ManageCustomTool.id]: ManageCustomTool, [ManageMcpTool.id]: ManageMcpTool, + [ManageSandbox.id]: ManageSandbox, [ManageScheduledTask.id]: ManageScheduledTask, [ManageSkill.id]: ManageSkill, [MaterializeFile.id]: MaterializeFile, diff --git a/apps/sim/lib/copilot/generated/tool-schemas-v1.ts b/apps/sim/lib/copilot/generated/tool-schemas-v1.ts index 3f981bbaccc..99867c46962 100644 --- a/apps/sim/lib/copilot/generated/tool-schemas-v1.ts +++ b/apps/sim/lib/copilot/generated/tool-schemas-v1.ts @@ -2211,7 +2211,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { code: { type: 'string', description: - 'Code to execute. For JS: raw statements auto-wrapped in async context. For Python: full script. For shell: bash script with pre-installed CLI tools. Request each needed secret with an explicit {{VAR_NAME}} reference.', + 'Code to execute. For JS: raw statements auto-wrapped in async context. For Python: full script. For shell: bash script with pre-installed CLI tools. Use each needed secret as {{VAR_NAME}}; the reference resolves to the value exactly as stored.', }, inputs: { type: 'object', @@ -3496,6 +3496,45 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, + manage_sandbox: { + parameters: { + type: 'object', + properties: { + dependencies: { + type: 'array', + description: + 'The COMPLETE package list, one package per entry — this REPLACES the existing list, it does not append. To add a package, call list first and resend the existing entries plus the new one. Optional for add (defaults to empty) and for edit.', + items: { + type: 'string', + }, + }, + language: { + type: 'string', + description: + "The sandbox's runtime. Required for add; on edit it re-validates the whole dependency list against the new language, so never switch language while leaving packages from the other ecosystem in place.", + enum: ['javascript', 'python'], + }, + name: { + type: 'string', + description: + 'Sandbox display name, unique within the workspace (max 64 characters). Required for add, optional for edit.', + }, + operation: { + type: 'string', + description: + "The operation to perform: 'add', 'edit', 'list', or 'delete'. These verbs are tool-specific — manage_scheduled_task uses create/update instead of add/edit.", + enum: ['add', 'edit', 'delete', 'list'], + }, + sandboxId: { + type: 'string', + description: + "The sandbox's id, from the `list` operation. Do not guess or construct it. Required for edit and delete; omit for add and list.", + }, + }, + required: ['operation'], + }, + resultSchema: undefined, + }, manage_scheduled_task: { parameters: { type: 'object', @@ -4185,7 +4224,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { code: { type: 'string', description: - 'Code to execute. For JS: raw statements auto-wrapped in async context. For Python: full script. For shell: bash script with pre-installed CLI tools. Request each needed secret with an explicit {{VAR_NAME}} reference.', + 'Code to execute. For JS: raw statements auto-wrapped in async context. For Python: full script. For shell: bash script with pre-installed CLI tools. Use each needed secret as {{VAR_NAME}}; the reference resolves to the value exactly as stored.', }, inputs: { type: 'object', diff --git a/apps/sim/lib/copilot/tool-executor/register-handlers.ts b/apps/sim/lib/copilot/tool-executor/register-handlers.ts index 29e18387f7c..adaa4d60d02 100644 --- a/apps/sim/lib/copilot/tool-executor/register-handlers.ts +++ b/apps/sim/lib/copilot/tool-executor/register-handlers.ts @@ -29,6 +29,7 @@ import { ManageCredential, ManageCustomTool, ManageMcpTool, + ManageSandbox, ManageScheduledTask, ManageSkill, MaterializeFile, @@ -84,6 +85,7 @@ import { import { executeManageCredential } from '../tools/handlers/management/manage-credential' import { executeManageCustomTool } from '../tools/handlers/management/manage-custom-tool' import { executeManageMcpTool } from '../tools/handlers/management/manage-mcp-tool' +import { executeManageSandbox } from '../tools/handlers/management/manage-sandbox' import { executeManageSkill } from '../tools/handlers/management/manage-skill' import { executeMaterializeFile } from '../tools/handlers/materialize-file' import { executeOAuthGetAuthLink, executeOAuthRequestAccess } from '../tools/handlers/oauth' @@ -192,6 +194,7 @@ function buildHandlerMap(): Record { [ManageCustomTool.id]: h(executeManageCustomTool), [ManageMcpTool.id]: h(executeManageMcpTool), + [ManageSandbox.id]: h(executeManageSandbox), [ManageSkill.id]: h(executeManageSkill), [ManageCredential.id]: h(executeManageCredential), [OauthGetAuthLink.id]: h(executeOAuthGetAuthLink), diff --git a/apps/sim/lib/copilot/tools/handlers/management/manage-sandbox.test.ts b/apps/sim/lib/copilot/tools/handlers/management/manage-sandbox.test.ts new file mode 100644 index 00000000000..a76d3076dcd --- /dev/null +++ b/apps/sim/lib/copilot/tools/handlers/management/manage-sandbox.test.ts @@ -0,0 +1,276 @@ +/** + * @vitest-environment node + */ + +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { ExecutionContext } from '@/lib/copilot/request/types' + +const { + hasWorkspaceSandboxAccessMock, + enforceWorkspaceRateLimitMock, + createWorkspaceSandboxMock, + updateWorkspaceSandboxMock, + deleteWorkspaceSandboxMock, + listWorkspaceSandboxesMock, +} = vi.hoisted(() => ({ + hasWorkspaceSandboxAccessMock: vi.fn(), + enforceWorkspaceRateLimitMock: vi.fn(), + createWorkspaceSandboxMock: vi.fn(), + updateWorkspaceSandboxMock: vi.fn(), + deleteWorkspaceSandboxMock: vi.fn(), + listWorkspaceSandboxesMock: vi.fn(), +})) + +vi.mock('@/lib/billing/core/subscription', () => ({ + hasWorkspaceSandboxAccess: hasWorkspaceSandboxAccessMock, +})) + +vi.mock('@/lib/core/rate-limiter/route-helpers', () => ({ + enforceWorkspaceRateLimit: enforceWorkspaceRateLimitMock, +})) + +vi.mock('@/lib/execution/remote-sandbox/workspace-sandboxes', () => ({ + createWorkspaceSandbox: createWorkspaceSandboxMock, + updateWorkspaceSandbox: updateWorkspaceSandboxMock, + deleteWorkspaceSandbox: deleteWorkspaceSandboxMock, + listWorkspaceSandboxes: listWorkspaceSandboxesMock, + currentSandboxStrategy: () => 'prebuilt', + MAX_PLAN_REQUIRED: 'Sandboxes require an active Max or Enterprise plan.', + SANDBOX_ADMIN_REQUIRED: 'Only workspace admins can manage sandboxes', + SANDBOX_MUTATION_LIMIT: { maxTokens: 20, refillRate: 10, refillIntervalMs: 60_000 }, +})) + +import { executeManageSandbox } from '@/lib/copilot/tools/handlers/management/manage-sandbox' + +const context = { + userId: 'user-1', + workflowId: 'wf-1', + workspaceId: 'ws-1', + userPermission: 'admin', +} as ExecutionContext + +const sandbox = { + id: 'sb-1', + name: 'data-tools', + language: 'python' as const, + dependencies: ['requests'], + buildStatus: 'pending' as const, + errorCode: null, + errorMessage: null, + errorDetail: null, + builtAt: null, + createdAt: '2026-01-01T00:00:00.000Z', + updatedAt: '2026-01-01T00:00:00.000Z', +} + +describe('manage_sandbox', () => { + beforeEach(() => { + vi.clearAllMocks() + hasWorkspaceSandboxAccessMock.mockResolvedValue(true) + enforceWorkspaceRateLimitMock.mockResolvedValue(null) + listWorkspaceSandboxesMock.mockResolvedValue([sandbox]) + createWorkspaceSandboxMock.mockResolvedValue({ ok: true, sandbox }) + updateWorkspaceSandboxMock.mockResolvedValue({ ok: true, sandbox }) + deleteWorkspaceSandboxMock.mockResolvedValue({ ok: true, name: sandbox.name }) + }) + + it('rejects a missing operation', async () => { + const result = await executeManageSandbox({}, context) + expect(result.success).toBe(false) + expect(result.error).toContain('operation') + }) + + it('ignores a model-supplied workspaceId and uses the server context', async () => { + await executeManageSandbox({ operation: 'list', workspaceId: 'other-ws' }, context) + expect(listWorkspaceSandboxesMock).toHaveBeenCalledWith('ws-1') + }) + + it('lists without spending the mutation budget or the plan check', async () => { + const result = await executeManageSandbox({ operation: 'list' }, { + ...context, + userPermission: 'write', + } as ExecutionContext) + expect(result.success).toBe(true) + expect(result.output).toMatchObject({ count: 1, strategy: 'prebuilt' }) + const [listed] = (result.output as { sandboxes: Record[] }).sandboxes + expect(listed).not.toHaveProperty('errorDetail') + expect(listed).toMatchObject({ id: 'sb-1', buildStatus: 'pending' }) + expect(enforceWorkspaceRateLimitMock).not.toHaveBeenCalled() + expect(hasWorkspaceSandboxAccessMock).not.toHaveBeenCalled() + }) + + it.each(['add', 'edit', 'delete'])('requires workspace admin to %s', async (operation) => { + const result = await executeManageSandbox( + { operation, name: 'x', language: 'python', sandboxId: 'sb-1' }, + { ...context, userPermission: 'write' } as ExecutionContext + ) + + expect(result.success).toBe(false) + expect(result.error).toBe('Only workspace admins can manage sandboxes') + expect(createWorkspaceSandboxMock).not.toHaveBeenCalled() + expect(updateWorkspaceSandboxMock).not.toHaveBeenCalled() + expect(deleteWorkspaceSandboxMock).not.toHaveBeenCalled() + }) + + it('refuses a write on a workspace without the plan entitlement', async () => { + hasWorkspaceSandboxAccessMock.mockResolvedValue(false) + + const result = await executeManageSandbox( + { operation: 'add', name: 'data-tools', language: 'python' }, + context + ) + + expect(result.success).toBe(false) + expect(result.error).toBe('Sandboxes require an active Max or Enterprise plan.') + expect(createWorkspaceSandboxMock).not.toHaveBeenCalled() + }) + + it('refuses a write when the workspace mutation budget is exhausted', async () => { + enforceWorkspaceRateLimitMock.mockResolvedValue({ status: 429 }) + + const result = await executeManageSandbox( + { operation: 'add', name: 'data-tools', language: 'python' }, + context + ) + + expect(enforceWorkspaceRateLimitMock).toHaveBeenCalledWith( + 'sandbox-mutations', + 'ws-1', + expect.anything() + ) + expect(result.success).toBe(false) + expect(result.error).toContain('Rate limit exceeded') + expect(createWorkspaceSandboxMock).not.toHaveBeenCalled() + }) + + it('creates a sandbox', async () => { + const result = await executeManageSandbox( + { operation: 'add', name: ' data-tools ', language: 'Python', dependencies: ['requests'] }, + context + ) + + expect(createWorkspaceSandboxMock).toHaveBeenCalledWith({ + workspaceId: 'ws-1', + userId: 'user-1', + name: ' data-tools ', + language: 'python', + dependencies: ['requests'], + }) + expect(result.success).toBe(true) + expect(result.output).toMatchObject({ sandboxId: 'sb-1' }) + }) + + it('rejects an unrecognized language rather than writing it', async () => { + const result = await executeManageSandbox( + { operation: 'add', name: 'data-tools', language: 'ruby' }, + context + ) + + expect(result.success).toBe(false) + expect(result.error).toContain('javascript or python') + expect(createWorkspaceSandboxMock).not.toHaveBeenCalled() + }) + + it('rejects an edit that changes nothing', async () => { + const result = await executeManageSandbox({ operation: 'edit', sandboxId: 'sb-1' }, context) + + expect(result.success).toBe(false) + expect(result.error).toContain('At least one of') + expect(updateWorkspaceSandboxMock).not.toHaveBeenCalled() + }) + + it('reports a rejected dependency with its line number', async () => { + updateWorkspaceSandboxMock.mockResolvedValue({ + ok: false, + failure: { + code: 'invalid_dependencies', + message: 'Invalid dependency list', + issues: [{ line: 2, value: 'not a package!', reason: 'not a valid package name' }], + }, + }) + + const result = await executeManageSandbox( + { operation: 'edit', sandboxId: 'sb-1', dependencies: ['requests', 'not a package!'] }, + context + ) + + expect(result.success).toBe(false) + expect(result.error).toContain('line 2') + expect(result.error).toContain('not a valid package name') + }) + + it('reports a name conflict', async () => { + createWorkspaceSandboxMock.mockResolvedValue({ + ok: false, + failure: { code: 'name_conflict', name: 'data-tools' }, + }) + + const result = await executeManageSandbox( + { operation: 'add', name: 'data-tools', language: 'python' }, + context + ) + + expect(result.success).toBe(false) + expect(result.error).toContain('already exists') + }) + + it('deletes a sandbox and warns that selecting blocks will fail', async () => { + const result = await executeManageSandbox({ operation: 'delete', sandboxId: 'sb-1' }, context) + + expect(deleteWorkspaceSandboxMock).toHaveBeenCalledWith('ws-1', 'sb-1') + expect(result.success).toBe(true) + expect((result.output as { message: string }).message).toContain('will fail') + }) + + it('reports a delete of an unknown sandbox', async () => { + deleteWorkspaceSandboxMock.mockResolvedValue({ + ok: false, + failure: { code: 'not_found', sandboxId: 'sb-9' }, + }) + + const result = await executeManageSandbox({ operation: 'delete', sandboxId: 'sb-9' }, context) + + expect(result.success).toBe(false) + expect(result.error).toContain('sb-9') + }) + + it('forwards a whitespace-only name to the operation, which refuses it', async () => { + await executeManageSandbox({ operation: 'edit', sandboxId: 'sb-1', name: ' ' }, context) + + expect(updateWorkspaceSandboxMock).toHaveBeenCalledWith( + expect.objectContaining({ name: ' ' }) + ) + }) + + it('rejects a non-string dependency list', async () => { + const result = await executeManageSandbox( + { operation: 'add', name: 'data-tools', language: 'python', dependencies: [1, 2] }, + context + ) + + expect(result.success).toBe(false) + expect(result.error).toContain('array of strings') + expect(createWorkspaceSandboxMock).not.toHaveBeenCalled() + }) + + it('surfaces an invalid name refused by the operation', async () => { + createWorkspaceSandboxMock.mockResolvedValue({ + ok: false, + failure: { code: 'invalid_name', message: 'Name must be 64 characters or fewer' }, + }) + + const result = await executeManageSandbox( + { operation: 'add', name: 'x'.repeat(65), language: 'python' }, + context + ) + + expect(result.success).toBe(false) + expect(result.error).toBe('Name must be 64 characters or fewer') + }) + + it('rejects an unsupported operation', async () => { + const result = await executeManageSandbox({ operation: 'rebuild' }, context) + expect(result.success).toBe(false) + expect(result.error).toContain('Unsupported operation') + }) +}) diff --git a/apps/sim/lib/copilot/tools/handlers/management/manage-sandbox.ts b/apps/sim/lib/copilot/tools/handlers/management/manage-sandbox.ts new file mode 100644 index 00000000000..5db2b1b107e --- /dev/null +++ b/apps/sim/lib/copilot/tools/handlers/management/manage-sandbox.ts @@ -0,0 +1,242 @@ +import { createLogger } from '@sim/logger' +import { getErrorMessage, toError } from '@sim/utils/errors' +import { hasWorkspaceSandboxAccess } from '@/lib/billing/core/subscription' +import type { ExecutionContext, ToolCallResult } from '@/lib/copilot/request/types' +import { copilotToolCanAdmin } from '@/lib/copilot/tools/permissions' +import { enforceWorkspaceRateLimit } from '@/lib/core/rate-limiter/route-helpers' +import { + isSandboxLanguage, + SANDBOX_LANGUAGES, + type SandboxLanguage, +} from '@/lib/execution/remote-sandbox/sandbox-spec' +import { + createWorkspaceSandbox, + currentSandboxStrategy, + deleteWorkspaceSandbox, + listWorkspaceSandboxes, + MAX_PLAN_REQUIRED, + SANDBOX_ADMIN_REQUIRED, + SANDBOX_MUTATION_LIMIT, + type SandboxWriteFailure, + updateWorkspaceSandbox, +} from '@/lib/execution/remote-sandbox/workspace-sandboxes' + +const logger = createLogger('CopilotToolExecutor') + +type ManageSandboxOperation = 'add' | 'edit' | 'delete' | 'list' + +const WRITE_OPERATIONS: readonly string[] = ['add', 'edit', 'delete'] + +const LANGUAGE_REQUIRED = `'language' must be ${SANDBOX_LANGUAGES.join(' or ')}` + +interface ManageSandboxParams { + operation?: string + sandboxId?: string + name?: string + language?: string + dependencies?: string[] +} + +function failureMessage(failure: SandboxWriteFailure): string { + switch (failure.code) { + case 'invalid_name': + return failure.message + case 'name_conflict': + return `A sandbox named "${failure.name}" already exists in this workspace` + case 'invalid_dependencies': { + const lines = failure.issues + .map((issue) => `line ${issue.line} ("${issue.value}"): ${issue.reason}`) + .join('; ') + return `Invalid dependency list — ${lines}` + } + case 'not_found': + return `Sandbox not found: ${failure.sandboxId}` + case 'read_back_failed': + return 'The sandbox was saved but could not be read back' + } +} + +function parseLanguage(value: unknown): SandboxLanguage | undefined { + if (typeof value !== 'string') return undefined + const normalized = value.toLowerCase() + return isSandboxLanguage(normalized) ? normalized : undefined +} + +function isStringArray(value: unknown): value is string[] { + return Array.isArray(value) && value.every((entry) => typeof entry === 'string') +} + +/** + * Reproduces the REST routes' gate — workspace admin, plan entitlement, then the + * shared mutation budget — so chat cannot create a sandbox the same user could + * not create in Settings > Sandboxes. + */ +export async function executeManageSandbox( + rawParams: Record, + context: ExecutionContext +): Promise { + const params = rawParams as ManageSandboxParams + const operation = String(params.operation || '').toLowerCase() as ManageSandboxOperation + // Server-set only: a model-supplied workspaceId would be authorized against + // the context workspace. Matches manage_custom_tool. + const workspaceId = context.workspaceId + + if (!operation) { + return { success: false, error: "Missing required 'operation' argument" } + } + if (!workspaceId) { + return { success: false, error: 'workspaceId is required' } + } + + const isWrite = WRITE_OPERATIONS.includes(operation) + + try { + if (isWrite) { + if (!copilotToolCanAdmin(context.userPermission)) { + return { success: false, error: SANDBOX_ADMIN_REQUIRED } + } + if (!(await hasWorkspaceSandboxAccess(workspaceId))) { + return { success: false, error: MAX_PLAN_REQUIRED } + } + if ( + await enforceWorkspaceRateLimit('sandbox-mutations', workspaceId, SANDBOX_MUTATION_LIMIT) + ) { + return { + success: false, + error: 'Rate limit exceeded for sandbox changes in this workspace. Try again shortly.', + } + } + } + + if (params.dependencies !== undefined && !isStringArray(params.dependencies)) { + return { success: false, error: "'dependencies' must be an array of strings" } + } + + if (operation === 'list') { + const sandboxes = await listWorkspaceSandboxes(workspaceId) + return { + success: true, + output: { + success: true, + operation, + // errorDetail is a 4KB installer log tail per failed build; errorMessage + // is the classified summary, and is all the model is told to read. + sandboxes: sandboxes.map(({ errorDetail, ...sandbox }) => sandbox), + count: sandboxes.length, + strategy: currentSandboxStrategy(), + }, + } + } + + if (operation === 'add') { + if (typeof params.name !== 'string' || !params.name.trim()) { + return { success: false, error: "'name' is required for operation 'add'" } + } + const language = parseLanguage(params.language) + if (!language) { + return { + success: false, + error: `'language' is required for operation 'add' — ${LANGUAGE_REQUIRED}`, + } + } + const result = await createWorkspaceSandbox({ + workspaceId, + userId: context.userId, + name: params.name, + language, + dependencies: params.dependencies ?? [], + }) + if (!result.ok) return { success: false, error: failureMessage(result.failure) } + + return { + success: true, + output: { + success: true, + operation, + sandboxId: result.sandbox.id, + sandbox: result.sandbox, + message: `Created sandbox "${result.sandbox.name}"`, + }, + } + } + + if (operation === 'edit') { + if (!params.sandboxId) { + return { success: false, error: "'sandboxId' is required for operation 'edit'" } + } + if ( + params.name === undefined && + params.language === undefined && + params.dependencies === undefined + ) { + return { + success: false, + error: "At least one of 'name', 'language', or 'dependencies' is required for 'edit'", + } + } + const language = parseLanguage(params.language) + if (params.language !== undefined && !language) { + return { success: false, error: LANGUAGE_REQUIRED } + } + if (params.name !== undefined && typeof params.name !== 'string') { + return { success: false, error: "'name' must be a string" } + } + const result = await updateWorkspaceSandbox({ + workspaceId, + sandboxId: params.sandboxId, + name: params.name, + language, + dependencies: params.dependencies, + }) + if (!result.ok) return { success: false, error: failureMessage(result.failure) } + + return { + success: true, + output: { + success: true, + operation, + sandboxId: result.sandbox.id, + sandbox: result.sandbox, + message: `Updated sandbox "${result.sandbox.name}"`, + }, + } + } + + if (operation === 'delete') { + if (!params.sandboxId) { + return { success: false, error: "'sandboxId' is required for operation 'delete'" } + } + + const result = await deleteWorkspaceSandbox(workspaceId, params.sandboxId) + if (!result.ok) return { success: false, error: failureMessage(result.failure) } + + return { + success: true, + output: { + success: true, + operation, + sandboxId: params.sandboxId, + message: `Deleted sandbox "${result.name}". Blocks still selecting it will fail until they are pointed at another sandbox.`, + }, + } + } + + return { success: false, error: `Unsupported operation for manage_sandbox: ${operation}` } + } catch (error) { + logger.error( + context.messageId + ? `manage_sandbox execution failed [messageId:${context.messageId}]` + : 'manage_sandbox execution failed', + { + operation, + workspaceId, + userId: context.userId, + error: toError(error).message, + } + ) + return { + success: false, + error: getErrorMessage(error, 'Failed to manage sandbox'), + } + } +} diff --git a/apps/sim/lib/copilot/tools/permissions.ts b/apps/sim/lib/copilot/tools/permissions.ts index 52e5b55ed91..23bd2bfa122 100644 --- a/apps/sim/lib/copilot/tools/permissions.ts +++ b/apps/sim/lib/copilot/tools/permissions.ts @@ -9,6 +9,14 @@ export function copilotToolCanWrite(userPermission: string | null | undefined): return permissionSatisfies((userPermission ?? null) as PermissionType | null, 'write') } +/** + * Whether a copilot tool call may perform an admin-only action. Same fail-closed + * contract as {@link copilotToolCanWrite}. + */ +export function copilotToolCanAdmin(userPermission: string | null | undefined): boolean { + return permissionSatisfies((userPermission ?? null) as PermissionType | null, 'admin') +} + /** Renders the denial message shared by both copilot execution paths. */ export function copilotWriteDeniedMessage( toolName: string, diff --git a/apps/sim/lib/copilot/tools/tool-display.test.ts b/apps/sim/lib/copilot/tools/tool-display.test.ts index feaa674f753..c3aa538c79e 100644 --- a/apps/sim/lib/copilot/tools/tool-display.test.ts +++ b/apps/sim/lib/copilot/tools/tool-display.test.ts @@ -308,6 +308,7 @@ describe('getToolDisplayTitle for managed resources', () => { ], ['manage_mcp_tool', { operation: 'edit', config: { name: 'Linear' } }, 'Updating Linear'], ['manage_skill', { operation: 'delete', name: 'sales-research' }, 'Deleting sales-research'], + ['manage_sandbox', { operation: 'add', name: 'data-tools' }, 'Creating data-tools'], [ 'manage_scheduled_task', { operation: 'create', args: { title: 'Morning Digest' } }, @@ -326,6 +327,7 @@ describe('getToolDisplayTitle for managed resources', () => { ['manage_custom_tool', { operation: 'list' }, 'Viewing custom tools'], ['manage_mcp_tool', { operation: 'list' }, 'Viewing MCP servers'], ['manage_skill', { operation: 'list' }, 'Viewing skills'], + ['manage_sandbox', { operation: 'list' }, 'Viewing sandboxes'], ['manage_scheduled_task', { operation: 'get' }, 'Reading scheduled task'], ['manage_scheduled_task', { operation: 'list' }, 'Viewing scheduled tasks'], ])('uses verb + resource name for %s', (toolName, args, expected) => { diff --git a/apps/sim/lib/copilot/tools/tool-display.ts b/apps/sim/lib/copilot/tools/tool-display.ts index 091d289a5b1..6f539002d89 100644 --- a/apps/sim/lib/copilot/tools/tool-display.ts +++ b/apps/sim/lib/copilot/tools/tool-display.ts @@ -883,6 +883,15 @@ export function getToolDisplayTitle(name: string, args?: Record list: { verb: 'Viewing', resource: 'MCP servers' }, }) } + case 'manage_sandbox': { + const target = firstStringArg(args, 'name') + return namedOperationTitle(args, target, 'Sandbox action', { + add: { verb: 'Creating', resource: 'sandbox' }, + edit: { verb: 'Updating', resource: 'sandbox' }, + delete: { verb: 'Deleting', resource: 'sandbox' }, + list: { verb: 'Viewing', resource: 'sandboxes' }, + }) + } case 'manage_skill': { const target = firstStringArg(args, 'name', 'skillName', 'title') return namedOperationTitle(args, target, 'Skill action', { diff --git a/apps/sim/lib/execution/remote-sandbox/workspace-sandboxes.test.ts b/apps/sim/lib/execution/remote-sandbox/workspace-sandboxes.test.ts new file mode 100644 index 00000000000..d10d8817f46 --- /dev/null +++ b/apps/sim/lib/execution/remote-sandbox/workspace-sandboxes.test.ts @@ -0,0 +1,147 @@ +/** + * @vitest-environment node + */ + +import { dbChainMockFns, queueTableRows, resetDbChainMock, schemaMock } from '@sim/testing' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +vi.mock('@/lib/execution/remote-sandbox/image-registry', () => ({ + ensureSandboxImage: vi.fn(), + releaseSandboxImage: vi.fn(), +})) + +vi.mock('@/lib/execution/remote-sandbox/resolve', () => ({ + invalidateSandboxResolution: vi.fn(), +})) + +vi.mock('@/lib/execution/remote-sandbox/provider', () => ({ + resolveProvider: () => ({ id: 'e2b', dependencyStrategy: 'runtime' }), +})) + +vi.mock('@/lib/core/utils/background', () => ({ + runDetached: vi.fn(), +})) + +import { + createWorkspaceSandbox, + updateWorkspaceSandbox, +} from '@/lib/execution/remote-sandbox/workspace-sandboxes' + +const { workspaceSandbox } = schemaMock + +const existingRow = { + id: 'sb-1', + name: 'data-tools', + language: 'python', + dependencies: ['requests'], + specHash: 'hash-1', + createdAt: new Date('2026-01-01T00:00:00.000Z'), + updatedAt: new Date('2026-01-01T00:00:00.000Z'), +} + +describe('workspace sandbox operations', () => { + beforeEach(() => { + vi.clearAllMocks() + resetDbChainMock() + }) + + describe('name validation', () => { + it.each([ + ['empty', '', 'Name is required'], + ['whitespace-only', ' ', 'Name is required'], + ['over 64 characters', 'x'.repeat(65), 'Name must be 64 characters or fewer'], + ])('refuses a %s name on create', async (_label, name, message) => { + const result = await createWorkspaceSandbox({ + workspaceId: 'ws-1', + userId: 'user-1', + name, + language: 'python', + dependencies: [], + }) + + expect(result).toEqual({ ok: false, failure: { code: 'invalid_name', message } }) + expect(dbChainMockFns.insert).not.toHaveBeenCalled() + }) + + it('trims a create name before storing it', async () => { + queueTableRows(workspaceSandbox, []) // name-taken pre-check + queueTableRows(workspaceSandbox, [{ ...existingRow, name: 'data-tools' }]) // read-back + + await createWorkspaceSandbox({ + workspaceId: 'ws-1', + userId: 'user-1', + name: ' data-tools ', + language: 'python', + dependencies: [], + }) + + expect(dbChainMockFns.values).toHaveBeenCalledWith( + expect.objectContaining({ name: 'data-tools' }) + ) + }) + + it('refuses a whitespace-only name on edit instead of writing it', async () => { + queueTableRows(workspaceSandbox, [existingRow]) + + const result = await updateWorkspaceSandbox({ + workspaceId: 'ws-1', + sandboxId: 'sb-1', + name: ' ', + }) + + expect(result).toEqual({ + ok: false, + failure: { code: 'invalid_name', message: 'Name is required' }, + }) + expect(dbChainMockFns.update).not.toHaveBeenCalled() + }) + + it('keeps the stored name when the edit omits one', async () => { + queueTableRows(workspaceSandbox, [existingRow]) + queueTableRows(workspaceSandbox, [existingRow]) + + await updateWorkspaceSandbox({ + workspaceId: 'ws-1', + sandboxId: 'sb-1', + dependencies: ['requests', 'httpx'], + }) + + expect(dbChainMockFns.set).toHaveBeenCalledWith( + expect.objectContaining({ name: 'data-tools' }) + ) + }) + }) + + it('reports a missing sandbox on edit', async () => { + queueTableRows(workspaceSandbox, []) + + const result = await updateWorkspaceSandbox({ + workspaceId: 'ws-1', + sandboxId: 'sb-missing', + name: 'renamed', + }) + + expect(result).toEqual({ + ok: false, + failure: { code: 'not_found', sandboxId: 'sb-missing' }, + }) + expect(dbChainMockFns.update).not.toHaveBeenCalled() + }) + + it('refuses a dependency the language rejects, with the offending line', async () => { + const result = await createWorkspaceSandbox({ + workspaceId: 'ws-1', + userId: 'user-1', + name: 'data-tools', + language: 'python', + dependencies: ['requests', 'not a package!'], + }) + + expect(result.ok).toBe(false) + if (result.ok) return + expect(result.failure.code).toBe('invalid_dependencies') + if (result.failure.code !== 'invalid_dependencies') return + expect(result.failure.issues[0]).toMatchObject({ line: 2, value: 'not a package!' }) + expect(dbChainMockFns.insert).not.toHaveBeenCalled() + }) +}) diff --git a/apps/sim/lib/execution/remote-sandbox/workspace-sandboxes.ts b/apps/sim/lib/execution/remote-sandbox/workspace-sandboxes.ts index bbea96adcda..296629439fe 100644 --- a/apps/sim/lib/execution/remote-sandbox/workspace-sandboxes.ts +++ b/apps/sim/lib/execution/remote-sandbox/workspace-sandboxes.ts @@ -1,8 +1,15 @@ import { db } from '@sim/db' import { sandboxImage, workspaceSandbox } from '@sim/db/schema' +import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' +import { generateId } from '@sim/utils/id' import { and, eq, inArray } from 'drizzle-orm' -import type { Sandbox } from '@/lib/api/contracts/sandboxes' -import { ensureSandboxImage } from '@/lib/execution/remote-sandbox/image-registry' +import { type Sandbox, sandboxNameSchema } from '@/lib/api/contracts/sandboxes' +import { runDetached } from '@/lib/core/utils/background' +import { + ensureSandboxImage, + releaseSandboxImage, +} from '@/lib/execution/remote-sandbox/image-registry' import { resolveProvider } from '@/lib/execution/remote-sandbox/provider' import { invalidateSandboxResolution } from '@/lib/execution/remote-sandbox/resolve' import { @@ -13,6 +20,8 @@ import { } from '@/lib/execution/remote-sandbox/sandbox-spec' import type { SandboxDependencyStrategy } from '@/lib/execution/remote-sandbox/types' +const logger = createLogger('WorkspaceSandboxes') + /** 403 copy for a workspace whose plan does not include sandbox authoring. */ export const MAX_PLAN_REQUIRED = 'Sandboxes require an active Max or Enterprise plan.' @@ -22,7 +31,7 @@ export const SANDBOX_ADMIN_REQUIRED = 'Only workspace admins can manage sandboxe * The unique index that actually arbitrates sandbox-name collisions. Named here * so a write path can recognize losing the race and answer 409 rather than 500. */ -export const WORKSPACE_SANDBOX_NAME_INDEX = 'workspace_sandbox_workspace_name_unique' +const WORKSPACE_SANDBOX_NAME_INDEX = 'workspace_sandbox_workspace_name_unique' /** * Builds cost provider compute, so every mutation shares one per-workspace @@ -34,15 +43,7 @@ export const SANDBOX_MUTATION_LIMIT = { refillIntervalMs: 60_000, } as const -/** Thrown when a submitted dependency list has lines the editor should mark. */ -export class SandboxDependencyError extends Error { - constructor(readonly issues: DependencyIssue[]) { - super(issues[0]?.reason ?? 'Invalid dependency list') - this.name = 'SandboxDependencyError' - } -} - -export interface SandboxSpecUpdate { +interface SandboxSpecUpdate { language: SandboxLanguage dependencies: string[] specHash: string @@ -53,16 +54,28 @@ export interface SandboxSpecUpdate { * canonical spec. Called on every write, including a language change, so a list * that was valid Python does not survive a switch to JavaScript unchecked. */ -export function buildSpecUpdate( +function buildSpecUpdate( language: SandboxLanguage, submitted: readonly string[] -): SandboxSpecUpdate { +): { ok: true; spec: SandboxSpecUpdate } | { ok: false; failure: SandboxWriteFailure } { const validation = validateDependencies(language, submitted) - if (!validation.ok) throw new SandboxDependencyError(validation.issues) + if (!validation.ok) { + return { + ok: false, + failure: { + code: 'invalid_dependencies', + message: validation.issues[0]?.reason ?? 'Invalid dependency list', + issues: validation.issues, + }, + } + } return { - language, - dependencies: validation.dependencies, - specHash: hashSandboxSpec({ language, dependencies: validation.dependencies }), + ok: true, + spec: { + language, + dependencies: validation.dependencies, + specHash: hashSandboxSpec({ language, dependencies: validation.dependencies }), + }, } } @@ -164,7 +177,7 @@ export async function listWorkspaceSandboxes(workspaceId: string): Promise { @@ -183,7 +196,7 @@ export async function readWorkspaceSandbox( * the resolution cache first means an execution started right after a save never * reads the previous image for the edited sandbox. */ -export async function scheduleSandboxBuild(spec: SandboxSpecUpdate): Promise { +async function scheduleSandboxBuild(spec: SandboxSpecUpdate): Promise { invalidateSandboxResolution() await ensureSandboxImage( { language: spec.language, dependencies: spec.dependencies }, @@ -192,7 +205,7 @@ export async function scheduleSandboxBuild(spec: SandboxSpecUpdate): Promise { + const sandbox = await readWorkspaceSandbox(workspaceId, sandboxId) + if (!sandbox) return { ok: false, failure: { code: 'read_back_failed' } } + return { ok: true, sandbox } +} + +export interface CreateWorkspaceSandboxParams { + workspaceId: string + userId: string + name: string + language: SandboxLanguage + dependencies: readonly string[] +} + +/** + * Authorization, entitlement, and rate limiting are the caller's job — the route + * and the copilot tool differ there. + */ +export async function createWorkspaceSandbox( + params: CreateWorkspaceSandboxParams +): Promise { + const { workspaceId, userId, language, dependencies } = params + + const normalized = normalizeSandboxName(params.name) + if (!normalized.ok) return normalized + const { name } = normalized + + const built = buildSpecUpdate(language, dependencies) + if (!built.ok) return built + const { spec } = built + + if (await isSandboxNameTaken(workspaceId, name)) { + return { ok: false, failure: { code: 'name_conflict', name } } + } + + const id = generateId() + try { + await db.insert(workspaceSandbox).values({ + id, + workspaceId, + name, + language: spec.language, + dependencies: spec.dependencies, + specHash: spec.specHash, + createdBy: userId, + }) + } catch (error) { + if (isSandboxNameConflictError(error)) { + return { ok: false, failure: { code: 'name_conflict', name } } + } + throw error + } + + await scheduleSandboxBuild(spec) + logger.info('Created workspace sandbox', { workspaceId, sandboxId: id, language: spec.language }) + return readBackOrFail(workspaceId, id) +} + +export interface UpdateWorkspaceSandboxParams { + workspaceId: string + sandboxId: string + name?: string + language?: SandboxLanguage + dependencies?: readonly string[] +} + +/** + * The build is scheduled unconditionally, 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 the caller is asking for. Gating 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. + */ +export async function updateWorkspaceSandbox( + params: UpdateWorkspaceSandboxParams +): Promise { + const { workspaceId, sandboxId, name, language, dependencies } = params + + const [existing] = await db + .select({ + 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 { ok: false, failure: { code: 'not_found', sandboxId } } + } + + // A supplied name is always validated, including a whitespace-only one: it + // trims to empty and must be refused, not fall back to the existing name. + let nextName = existing.name + if (name !== undefined) { + const normalized = normalizeSandboxName(name) + if (!normalized.ok) return normalized + nextName = normalized.name + } + + if (nextName !== existing.name && (await isSandboxNameTaken(workspaceId, nextName, sandboxId))) { + return { ok: false, failure: { code: 'name_conflict', name: nextName } } + } + + // 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 SandboxLanguage) + const nextDependencies = dependencies ?? existing.dependencies ?? [] + + const built = buildSpecUpdate(nextLanguage, nextDependencies) + if (!built.ok) return built + const { spec } = built + + 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) { + if (isSandboxNameConflictError(error)) { + return { ok: false, failure: { code: 'name_conflict', name: nextName } } + } + throw error + } + + 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 }) + } + + return readBackOrFail(workspaceId, sandboxId) +} + +/** + * A block may still reference the deleted sandbox; that execution fails closed + * naming the missing sandbox, rather than silently falling back to an image + * without its dependencies. + */ +export async function deleteWorkspaceSandbox( + workspaceId: string, + sandboxId: string +): Promise { + const deleted = await db + .delete(workspaceSandbox) + .where(and(eq(workspaceSandbox.id, sandboxId), eq(workspaceSandbox.workspaceId, workspaceId))) + .returning({ + name: workspaceSandbox.name, + specHash: workspaceSandbox.specHash, + }) + + if (deleted.length === 0) { + return { ok: false, failure: { code: 'not_found', sandboxId } } + } + + 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 { ok: true, name: deleted[0].name } +}