From e5af15d14ad94aef6876ff2c4956cfc2df6491de Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Mon, 3 Aug 2026 20:29:21 -0700 Subject: [PATCH 1/3] feat(copilot): let the mothership manage workspace sandboxes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../[id]/sandboxes/[sandboxId]/route.ts | 116 +------- .../workspaces/[id]/sandboxes/authorize.ts | 73 ++--- .../api/workspaces/[id]/sandboxes/route.ts | 65 ++--- .../lib/copilot/generated/tool-catalog-v1.ts | 65 ++++- .../lib/copilot/generated/tool-schemas-v1.ts | 43 ++- .../tool-executor/register-handlers.ts | 3 + .../management/manage-sandbox.test.ts | 242 +++++++++++++++++ .../handlers/management/manage-sandbox.ts | 250 ++++++++++++++++++ apps/sim/lib/copilot/tools/tool-display.ts | 9 + .../remote-sandbox/workspace-sandboxes.ts | 238 ++++++++++++++++- 10 files changed, 890 insertions(+), 214 deletions(-) create mode 100644 apps/sim/lib/copilot/tools/handlers/management/manage-sandbox.test.ts create mode 100644 apps/sim/lib/copilot/tools/handlers/management/manage-sandbox.ts 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..265040f07e1 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,32 @@ 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. - */ -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. + * Maps a refused write onto the status code the editor expects. Shared by both + * route files so the create path and the edit/delete path cannot describe the + * same failure differently. + * + * `invalid_dependencies` carries a line number per rejected row, which the + * generic validation error does not — the editor marks those inline. */ -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 '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 @@ -109,7 +79,6 @@ 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 }> { const session = await getSession() diff --git a/apps/sim/app/api/workspaces/[id]/sandboxes/route.ts b/apps/sim/app/api/workspaces/[id]/sandboxes/route.ts index f39bc6b4215..7800e515036 100644 --- a/apps/sim/app/api/workspaces/[id]/sandboxes/route.ts +++ b/apps/sim/app/api/workspaces/[id]/sandboxes/route.ts @@ -1,26 +1,18 @@ -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') @@ -29,7 +21,7 @@ export const GET = withRouteHandler( async (request: NextRequest, context: { params: Promise<{ id: string }> }) => { const workspaceId = (await context.params).id - const viewer = await authorizeSandboxRead(request, workspaceId) + const viewer = await authorizeSandboxRead(workspaceId) if (!viewer.ok) return viewer.response // The list itself is not plan-gated: a workspace that downgraded must still @@ -59,43 +51,20 @@ 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 }) + logger.info('Created workspace sandbox', { + workspaceId, + sandboxId: result.sandbox.id, + language, + }) + return NextResponse.json({ sandbox: result.sandbox }) } ) diff --git a/apps/sim/lib/copilot/generated/tool-catalog-v1.ts b/apps/sim/lib/copilot/generated/tool-catalog-v1.ts index 8200e4f02d1..88878fd4447 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,47 @@ 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'], + }, +} + export const ManageScheduledTask: ToolCatalogEntry = { id: 'manage_scheduled_task', name: 'manage_scheduled_task', @@ -4318,7 +4361,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 +5807,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 +6092,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..da9d890b503 --- /dev/null +++ b/apps/sim/lib/copilot/tools/handlers/management/manage-sandbox.test.ts @@ -0,0 +1,242 @@ +/** + * @vitest-environment node + */ + +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { ExecutionContext } from '@/lib/copilot/request/types' + +const { + ensureWorkspaceAccessMock, + hasWorkspaceSandboxAccessMock, + enforceWorkspaceRateLimitMock, + createWorkspaceSandboxMock, + updateWorkspaceSandboxMock, + deleteWorkspaceSandboxMock, + listWorkspaceSandboxesMock, +} = vi.hoisted(() => ({ + ensureWorkspaceAccessMock: vi.fn(), + hasWorkspaceSandboxAccessMock: vi.fn(), + enforceWorkspaceRateLimitMock: vi.fn(), + createWorkspaceSandboxMock: vi.fn(), + updateWorkspaceSandboxMock: vi.fn(), + deleteWorkspaceSandboxMock: vi.fn(), + listWorkspaceSandboxesMock: vi.fn(), +})) + +vi.mock('@/lib/copilot/tools/handlers/access', () => ({ + ensureWorkspaceAccess: ensureWorkspaceAccessMock, +})) + +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' } 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() + ensureWorkspaceAccessMock.mockResolvedValue({}) + 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(ensureWorkspaceAccessMock).toHaveBeenCalledWith('ws-1', 'user-1', 'read') + expect(listWorkspaceSandboxesMock).toHaveBeenCalledWith('ws-1') + }) + + it('lists with only read access, and does not spend the mutation budget', async () => { + const result = await executeManageSandbox({ operation: 'list' }, context) + expect(result.success).toBe(true) + expect(result.output).toMatchObject({ count: 1, strategy: 'prebuilt' }) + expect(enforceWorkspaceRateLimitMock).not.toHaveBeenCalled() + expect(hasWorkspaceSandboxAccessMock).not.toHaveBeenCalled() + }) + + it.each(['add', 'edit', 'delete'])('requires workspace admin to %s', async (operation) => { + ensureWorkspaceAccessMock.mockRejectedValue(new Error('Admin access required')) + + const result = await executeManageSandbox( + { operation, name: 'x', language: 'python', sandboxId: 'sb-1' }, + context + ) + + expect(ensureWorkspaceAccessMock).toHaveBeenCalledWith('ws-1', 'user-1', 'admin') + 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') + 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('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..69ca592f130 --- /dev/null +++ b/apps/sim/lib/copilot/tools/handlers/management/manage-sandbox.ts @@ -0,0 +1,250 @@ +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 { ensureWorkspaceAccess } from '@/lib/copilot/tools/handlers/access' +import { enforceWorkspaceRateLimit } from '@/lib/core/rate-limiter/route-helpers' +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'] + +type SandboxLanguage = 'javascript' | 'python' + +const SANDBOX_LANGUAGES: readonly string[] = ['javascript', 'python'] + +interface ManageSandboxParams { + operation?: string + sandboxId?: string + name?: string + language?: string + dependencies?: string[] +} + +/** Renders a refused write as the sentence the model reads back to the user. */ +function failureMessage(failure: SandboxWriteFailure): string { + switch (failure.code) { + 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' + } +} + +/** + * Validates the model-supplied language. The parameter is a string on the wire, + * so an unrecognized value must be rejected here rather than cast into the + * enum and written to a column that only accepts two values. + */ +function parseLanguage(value: string | undefined): SandboxLanguage | undefined { + if (value === undefined) return undefined + const normalized = value.toLowerCase() + return SANDBOX_LANGUAGES.includes(normalized) ? (normalized as SandboxLanguage) : undefined +} + +/** + * Sandbox CRUD for the mothership. + * + * Mirrors the REST routes' gate exactly — workspace admin, then plan + * entitlement, then the shared per-workspace mutation budget — so a sandbox + * cannot be created through chat that the same user could not create in + * Settings > Sandboxes. `list` is readable by any member, matching the GET + * route, because a downgraded workspace must still see what it already built. + */ +export async function executeManageSandbox( + rawParams: Record, + context: ExecutionContext +): Promise { + const params = rawParams as ManageSandboxParams + const operation = String(params.operation || '').toLowerCase() as ManageSandboxOperation + /** + * Server-set context only. A model-supplied `workspaceId` would be authorized + * against the context workspace, letting a caller name another workspace and + * have it checked against their own. 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 { + // Authorization runs before any argument is interpreted, and admin is + // required for writes — sandbox builds spend workspace compute. + try { + await ensureWorkspaceAccess(workspaceId, context.userId, isWrite ? 'admin' : 'read') + } catch { + return { + success: false, + error: isWrite ? SANDBOX_ADMIN_REQUIRED : 'You do not have access to this workspace', + } + } + + if (isWrite) { + if (!(await hasWorkspaceSandboxAccess(workspaceId))) { + return { success: false, error: MAX_PLAN_REQUIRED } + } + // The same bucket the REST routes spend, so chat cannot be used to double + // the workspace's build allowance. + 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 (operation === 'list') { + const sandboxes = await listWorkspaceSandboxes(workspaceId) + return { + success: true, + output: { + success: true, + operation, + sandboxes, + count: sandboxes.length, + strategy: currentSandboxStrategy(), + }, + } + } + + if (operation === 'add') { + const name = params.name?.trim() + if (!name) { + 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' and must be 'javascript' or 'python'", + } + } + + const result = await createWorkspaceSandbox({ + workspaceId, + userId: context.userId, + 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 "${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' must be 'javascript' or 'python'" } + } + + const result = await updateWorkspaceSandbox({ + workspaceId, + sandboxId: params.sandboxId, + name: params.name?.trim(), + 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/tool-display.ts b/apps/sim/lib/copilot/tools/tool-display.ts index 091d289a5b1..c69495fbaba 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', 'sandboxName') + 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.ts b/apps/sim/lib/execution/remote-sandbox/workspace-sandboxes.ts index bbea96adcda..3db94dfc617 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 { 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 @@ -35,14 +44,14 @@ export const SANDBOX_MUTATION_LIMIT = { } as const /** Thrown when a submitted dependency list has lines the editor should mark. */ -export class SandboxDependencyError extends Error { +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,7 +62,7 @@ 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 { @@ -164,7 +173,7 @@ export async function listWorkspaceSandboxes(workspaceId: string): Promise { @@ -183,7 +192,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 +201,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 + /** Attributed as `createdBy`; the caller has already authorized this actor. */ + userId: string + name: string + language: SandboxLanguage + /** Raw submitted lines — comments and blanks are stripped during validation. */ + dependencies: readonly string[] +} + +/** + * Creates a sandbox and enqueues its build. + * + * Authorization, entitlement, and rate limiting are the caller's job: this runs + * for both the REST route and the copilot tool, which authorize differently. + */ +export async function createWorkspaceSandbox( + params: CreateWorkspaceSandboxParams +): Promise { + const { workspaceId, userId, name, language, dependencies } = params + + let spec: SandboxSpecUpdate + try { + spec = buildSpecUpdate(language, dependencies) + } catch (error) { + return { ok: false, failure: dependencyFailure(error) } + } + + 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) { + // The unique index is the real arbiter — the pre-check above only exists to + // return a friendlier message when there is no race. + if (isSandboxNameConflictError(error)) { + return { ok: false, failure: { code: 'name_conflict', name } } + } + throw error + } + + await scheduleSandboxBuild(spec) + return readBackOrFail(workspaceId, id) +} + +export interface UpdateWorkspaceSandboxParams { + workspaceId: string + sandboxId: string + name?: string + language?: SandboxLanguage + dependencies?: readonly string[] +} + +/** + * Applies a partial edit and re-enqueues the build. + * + * 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 } } + } + + const nextName = name ?? existing.name + if (name && name !== existing.name && (await isSandboxNameTaken(workspaceId, name, sandboxId))) { + return { ok: false, failure: { code: 'name_conflict', 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 SandboxLanguage) + const nextDependencies = dependencies ?? existing.dependencies ?? [] + + let spec: SandboxSpecUpdate + try { + spec = buildSpecUpdate(nextLanguage, nextDependencies) + } catch (error) { + return { ok: false, failure: dependencyFailure(error) } + } + + 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) +} + +/** + * Deletes a sandbox and releases its build. + * + * A block may still reference it. 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. + */ +export async function deleteWorkspaceSandbox( + workspaceId: string, + sandboxId: string +): Promise<{ ok: true; name: string } | { ok: false; failure: SandboxWriteFailure }> { + 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 } +} From 42d8d6f91a2aa30985b00c137365ee58e84432f6 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Mon, 3 Aug 2026 20:49:25 -0700 Subject: [PATCH 2/3] refactor(copilot): tighten manage_sandbox after review MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixes found auditing the first pass: - updateWorkspaceSandbox treated a whitespace-only name as supplied, so it trimmed to empty, skipped the falsy conflict pre-check, and wrote an unnamed sandbox the UI cannot produce. Name normalization now runs in the operations layer and reuses the contract's own sandboxNameSchema, so the tool path — which has no schema in front of it — cannot accept a name the REST path rejects. - The handler resolved permissions through ensureWorkspaceAccess while the route used getUserEntityPermissions, so the same refusal was worded two ways. Both now use the same primitive and the same SANDBOX_ADMIN_REQUIRED constant, and a DB failure no longer reads as a permission denial. - list returned errorDetail, a 4KB installer log tail per failed build, on every call. errorMessage is the classified summary and is all the tool prompt advertises. - buildSpecUpdate now returns a result instead of throwing, dropping the SandboxDependencyError class, the rethrowing bridge helper, and both try/catch blocks. - Model-supplied dependencies are type-checked once before dispatch rather than per branch; the language error copy derives from SANDBOX_LANGUAGES. - Create logging moved into the operations layer so the tool's creates are logged too, and the route no longer carries a logger. - manage_sandbox was missing from the chat tool-icon map and fell through to the mothership Blimp fallback. --- .../workspaces/[id]/sandboxes/authorize.ts | 12 +- .../api/workspaces/[id]/sandboxes/route.ts | 12 +- .../home/components/message-content/utils.ts | 1 + apps/sim/lib/api/contracts/sandboxes.ts | 2 +- .../management/manage-sandbox.test.ts | 58 +++++-- .../handlers/management/manage-sandbox.ts | 89 +++++----- .../lib/copilot/tools/tool-display.test.ts | 2 + apps/sim/lib/copilot/tools/tool-display.ts | 2 +- .../workspace-sandboxes.test.ts | 153 ++++++++++++++++++ .../remote-sandbox/workspace-sandboxes.ts | 115 +++++++------ 10 files changed, 322 insertions(+), 124 deletions(-) create mode 100644 apps/sim/lib/execution/remote-sandbox/workspace-sandboxes.test.ts diff --git a/apps/sim/app/api/workspaces/[id]/sandboxes/authorize.ts b/apps/sim/app/api/workspaces/[id]/sandboxes/authorize.ts index 265040f07e1..57f1057b275 100644 --- a/apps/sim/app/api/workspaces/[id]/sandboxes/authorize.ts +++ b/apps/sim/app/api/workspaces/[id]/sandboxes/authorize.ts @@ -26,6 +26,8 @@ export interface SandboxMutationActor { */ 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` }, @@ -78,16 +80,14 @@ export async function authorizeSandboxMutation( } /** Reads a workspace sandbox list; any member may look, only admins may write. */ -export async function authorizeSandboxRead( - 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 7800e515036..80e78d428ae 100644 --- a/apps/sim/app/api/workspaces/[id]/sandboxes/route.ts +++ b/apps/sim/app/api/workspaces/[id]/sandboxes/route.ts @@ -1,4 +1,3 @@ -import { createLogger } from '@sim/logger' import { type NextRequest, NextResponse } from 'next/server' import { createSandboxContract } from '@/lib/api/contracts/sandboxes' import { parseRequest } from '@/lib/api/server' @@ -15,14 +14,12 @@ import { 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(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 @@ -60,11 +57,6 @@ export const POST = withRouteHandler( }) if (!result.ok) return sandboxFailureResponse(result.failure) - logger.info('Created workspace sandbox', { - workspaceId, - sandboxId: result.sandbox.id, - language, - }) 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/tools/handlers/management/manage-sandbox.test.ts b/apps/sim/lib/copilot/tools/handlers/management/manage-sandbox.test.ts index da9d890b503..67bc3030438 100644 --- a/apps/sim/lib/copilot/tools/handlers/management/manage-sandbox.test.ts +++ b/apps/sim/lib/copilot/tools/handlers/management/manage-sandbox.test.ts @@ -6,7 +6,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import type { ExecutionContext } from '@/lib/copilot/request/types' const { - ensureWorkspaceAccessMock, + getUserEntityPermissionsMock, hasWorkspaceSandboxAccessMock, enforceWorkspaceRateLimitMock, createWorkspaceSandboxMock, @@ -14,7 +14,7 @@ const { deleteWorkspaceSandboxMock, listWorkspaceSandboxesMock, } = vi.hoisted(() => ({ - ensureWorkspaceAccessMock: vi.fn(), + getUserEntityPermissionsMock: vi.fn(), hasWorkspaceSandboxAccessMock: vi.fn(), enforceWorkspaceRateLimitMock: vi.fn(), createWorkspaceSandboxMock: vi.fn(), @@ -23,8 +23,8 @@ const { listWorkspaceSandboxesMock: vi.fn(), })) -vi.mock('@/lib/copilot/tools/handlers/access', () => ({ - ensureWorkspaceAccess: ensureWorkspaceAccessMock, +vi.mock('@/lib/workspaces/permissions/utils', () => ({ + getUserEntityPermissions: getUserEntityPermissionsMock, })) vi.mock('@/lib/billing/core/subscription', () => ({ @@ -67,7 +67,7 @@ const sandbox = { describe('manage_sandbox', () => { beforeEach(() => { vi.clearAllMocks() - ensureWorkspaceAccessMock.mockResolvedValue({}) + getUserEntityPermissionsMock.mockResolvedValue('admin') hasWorkspaceSandboxAccessMock.mockResolvedValue(true) enforceWorkspaceRateLimitMock.mockResolvedValue(null) listWorkspaceSandboxesMock.mockResolvedValue([sandbox]) @@ -84,27 +84,31 @@ describe('manage_sandbox', () => { it('ignores a model-supplied workspaceId and uses the server context', async () => { await executeManageSandbox({ operation: 'list', workspaceId: 'other-ws' }, context) - expect(ensureWorkspaceAccessMock).toHaveBeenCalledWith('ws-1', 'user-1', 'read') + expect(getUserEntityPermissionsMock).toHaveBeenCalledWith('user-1', 'workspace', 'ws-1') expect(listWorkspaceSandboxesMock).toHaveBeenCalledWith('ws-1') }) it('lists with only read access, and does not spend the mutation budget', async () => { + getUserEntityPermissionsMock.mockResolvedValue('read') + const result = await executeManageSandbox({ operation: 'list' }, context) 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) => { - ensureWorkspaceAccessMock.mockRejectedValue(new Error('Admin access required')) + getUserEntityPermissionsMock.mockResolvedValue('write') const result = await executeManageSandbox( { operation, name: 'x', language: 'python', sandboxId: 'sb-1' }, context ) - expect(ensureWorkspaceAccessMock).toHaveBeenCalledWith('ws-1', 'user-1', 'admin') expect(result.success).toBe(false) expect(result.error).toBe('Only workspace admins can manage sandboxes') expect(createWorkspaceSandboxMock).not.toHaveBeenCalled() @@ -152,7 +156,7 @@ describe('manage_sandbox', () => { expect(createWorkspaceSandboxMock).toHaveBeenCalledWith({ workspaceId: 'ws-1', userId: 'user-1', - name: 'data-tools', + name: ' data-tools ', language: 'python', dependencies: ['requests'], }) @@ -167,7 +171,7 @@ describe('manage_sandbox', () => { ) expect(result.success).toBe(false) - expect(result.error).toContain('javascript') + expect(result.error).toContain('javascript or python') expect(createWorkspaceSandboxMock).not.toHaveBeenCalled() }) @@ -234,6 +238,40 @@ describe('manage_sandbox', () => { 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) diff --git a/apps/sim/lib/copilot/tools/handlers/management/manage-sandbox.ts b/apps/sim/lib/copilot/tools/handlers/management/manage-sandbox.ts index 69ca592f130..780535e8f5c 100644 --- a/apps/sim/lib/copilot/tools/handlers/management/manage-sandbox.ts +++ b/apps/sim/lib/copilot/tools/handlers/management/manage-sandbox.ts @@ -2,8 +2,12 @@ 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 { ensureWorkspaceAccess } from '@/lib/copilot/tools/handlers/access' 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, @@ -15,6 +19,7 @@ import { type SandboxWriteFailure, updateWorkspaceSandbox, } from '@/lib/execution/remote-sandbox/workspace-sandboxes' +import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils' const logger = createLogger('CopilotToolExecutor') @@ -22,9 +27,7 @@ type ManageSandboxOperation = 'add' | 'edit' | 'delete' | 'list' const WRITE_OPERATIONS: readonly string[] = ['add', 'edit', 'delete'] -type SandboxLanguage = 'javascript' | 'python' - -const SANDBOX_LANGUAGES: readonly string[] = ['javascript', 'python'] +const LANGUAGE_REQUIRED = `'language' must be ${SANDBOX_LANGUAGES.join(' or ')}` interface ManageSandboxParams { operation?: string @@ -34,9 +37,10 @@ interface ManageSandboxParams { dependencies?: string[] } -/** Renders a refused write as the sentence the model reads back to the user. */ 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': { @@ -52,25 +56,20 @@ function failureMessage(failure: SandboxWriteFailure): string { } } -/** - * Validates the model-supplied language. The parameter is a string on the wire, - * so an unrecognized value must be rejected here rather than cast into the - * enum and written to a column that only accepts two values. - */ -function parseLanguage(value: string | undefined): SandboxLanguage | undefined { - if (value === undefined) return undefined +function parseLanguage(value: unknown): SandboxLanguage | undefined { + if (typeof value !== 'string') return undefined const normalized = value.toLowerCase() - return SANDBOX_LANGUAGES.includes(normalized) ? (normalized as SandboxLanguage) : undefined + return isSandboxLanguage(normalized) ? normalized : undefined +} + +function isStringArray(value: unknown): value is string[] { + return Array.isArray(value) && value.every((entry) => typeof entry === 'string') } /** - * Sandbox CRUD for the mothership. - * - * Mirrors the REST routes' gate exactly — workspace admin, then plan - * entitlement, then the shared per-workspace mutation budget — so a sandbox - * cannot be created through chat that the same user could not create in - * Settings > Sandboxes. `list` is readable by any member, matching the GET - * route, because a downgraded workspace must still see what it already built. + * 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. `list` needs only read, matching GET. */ export async function executeManageSandbox( rawParams: Record, @@ -78,11 +77,8 @@ export async function executeManageSandbox( ): Promise { const params = rawParams as ManageSandboxParams const operation = String(params.operation || '').toLowerCase() as ManageSandboxOperation - /** - * Server-set context only. A model-supplied `workspaceId` would be authorized - * against the context workspace, letting a caller name another workspace and - * have it checked against their own. Matches manage_custom_tool. - */ + // Server-set only: a model-supplied workspaceId would be authorized against + // the context workspace. Matches manage_custom_tool. const workspaceId = context.workspaceId if (!operation) { @@ -95,23 +91,18 @@ export async function executeManageSandbox( const isWrite = WRITE_OPERATIONS.includes(operation) try { - // Authorization runs before any argument is interpreted, and admin is - // required for writes — sandbox builds spend workspace compute. - try { - await ensureWorkspaceAccess(workspaceId, context.userId, isWrite ? 'admin' : 'read') - } catch { - return { - success: false, - error: isWrite ? SANDBOX_ADMIN_REQUIRED : 'You do not have access to this workspace', - } + const permission = await getUserEntityPermissions(context.userId, 'workspace', workspaceId) + if (!permission) { + return { success: false, error: 'You do not have access to this workspace' } } if (isWrite) { + if (permission !== 'admin') { + return { success: false, error: SANDBOX_ADMIN_REQUIRED } + } if (!(await hasWorkspaceSandboxAccess(workspaceId))) { return { success: false, error: MAX_PLAN_REQUIRED } } - // The same bucket the REST routes spend, so chat cannot be used to double - // the workspace's build allowance. if ( await enforceWorkspaceRateLimit('sandbox-mutations', workspaceId, SANDBOX_MUTATION_LIMIT) ) { @@ -122,6 +113,10 @@ export async function executeManageSandbox( } } + 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 { @@ -129,7 +124,9 @@ export async function executeManageSandbox( output: { success: true, operation, - sandboxes, + // 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(), }, @@ -137,22 +134,20 @@ export async function executeManageSandbox( } if (operation === 'add') { - const name = params.name?.trim() - if (!name) { + 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' and must be 'javascript' or 'python'", + error: `'language' is required for operation 'add' — ${LANGUAGE_REQUIRED}`, } } - const result = await createWorkspaceSandbox({ workspaceId, userId: context.userId, - name, + name: params.name, language, dependencies: params.dependencies ?? [], }) @@ -165,7 +160,7 @@ export async function executeManageSandbox( operation, sandboxId: result.sandbox.id, sandbox: result.sandbox, - message: `Created sandbox "${name}"`, + message: `Created sandbox "${result.sandbox.name}"`, }, } } @@ -186,13 +181,15 @@ export async function executeManageSandbox( } const language = parseLanguage(params.language) if (params.language !== undefined && !language) { - return { success: false, error: "'language' must be 'javascript' or 'python'" } + 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?.trim(), + name: params.name, language, dependencies: params.dependencies, }) 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 c69495fbaba..6f539002d89 100644 --- a/apps/sim/lib/copilot/tools/tool-display.ts +++ b/apps/sim/lib/copilot/tools/tool-display.ts @@ -884,7 +884,7 @@ export function getToolDisplayTitle(name: string, args?: Record }) } case 'manage_sandbox': { - const target = firstStringArg(args, 'name', 'sandboxName') + const target = firstStringArg(args, 'name') return namedOperationTitle(args, target, 'Sandbox action', { add: { verb: 'Creating', resource: 'sandbox' }, edit: { verb: 'Updating', resource: 'sandbox' }, 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..c0c8aa93d73 --- /dev/null +++ b/apps/sim/lib/execution/remote-sandbox/workspace-sandboxes.test.ts @@ -0,0 +1,153 @@ +/** + * @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' }) + ) + }) + + /** + * The regression this guards: `nextName = name ?? existing.name` treated a + * whitespace-only name as "supplied", so it trimmed to empty, skipped the + * conflict pre-check (falsy), and wrote an unnamed sandbox the UI cannot + * create and the user cannot select. + */ + 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 3db94dfc617..ffe272816e0 100644 --- a/apps/sim/lib/execution/remote-sandbox/workspace-sandboxes.ts +++ b/apps/sim/lib/execution/remote-sandbox/workspace-sandboxes.ts @@ -4,7 +4,7 @@ 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 { type Sandbox, sandboxNameSchema } from '@/lib/api/contracts/sandboxes' import { runDetached } from '@/lib/core/utils/background' import { ensureSandboxImage, @@ -43,14 +43,6 @@ export const SANDBOX_MUTATION_LIMIT = { refillIntervalMs: 60_000, } as const -/** Thrown when a submitted dependency list has lines the editor should mark. */ -class SandboxDependencyError extends Error { - constructor(readonly issues: DependencyIssue[]) { - super(issues[0]?.reason ?? 'Invalid dependency list') - this.name = 'SandboxDependencyError' - } -} - interface SandboxSpecUpdate { language: SandboxLanguage dependencies: string[] @@ -65,13 +57,25 @@ interface SandboxSpecUpdate { 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 }), + }, } } @@ -224,11 +228,9 @@ function isSandboxNameConflictError(error: unknown): boolean { return message.includes(WORKSPACE_SANDBOX_NAME_INDEX) || message.includes('23505') } -/** - * Why a sandbox write was refused, in terms the caller's own surface can render: - * the REST routes map these to status codes, the copilot tool to a message. - */ +/** Why a write was refused, rendered by each caller for its own surface. */ export type SandboxWriteFailure = + | { code: 'invalid_name'; message: string } | { code: 'name_conflict'; name: string } | { code: 'invalid_dependencies'; message: string; issues: DependencyIssue[] } | { code: 'not_found'; sandboxId: string } @@ -238,11 +240,23 @@ export type SandboxWriteResult = | { ok: true; sandbox: Sandbox } | { ok: false; failure: SandboxWriteFailure } -function dependencyFailure(error: unknown): SandboxWriteFailure { - if (error instanceof SandboxDependencyError) { - return { code: 'invalid_dependencies', message: error.message, issues: error.issues } +export type SandboxDeleteResult = + | { ok: true; name: string } + | { ok: false; failure: SandboxWriteFailure } + +/** + * Reuses the contract's own name rule, so the copilot tool — which has no schema + * in front of it — cannot accept a name the REST path would reject. + */ +function normalizeSandboxName( + raw: string +): { ok: true; name: string } | { ok: false; failure: SandboxWriteFailure } { + const parsed = sandboxNameSchema.safeParse(raw) + if (!parsed.success) { + const message = parsed.error.issues[0]?.message ?? 'Invalid sandbox name' + return { ok: false, failure: { code: 'invalid_name', message } } } - throw error + return { ok: true, name: parsed.data } } async function readBackOrFail(workspaceId: string, sandboxId: string): Promise { @@ -262,22 +276,21 @@ export interface CreateWorkspaceSandboxParams { } /** - * Creates a sandbox and enqueues its build. - * - * Authorization, entitlement, and rate limiting are the caller's job: this runs - * for both the REST route and the copilot tool, which authorize differently. + * Creates a sandbox and enqueues its build. 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, name, language, dependencies } = params + const { workspaceId, userId, language, dependencies } = params - let spec: SandboxSpecUpdate - try { - spec = buildSpecUpdate(language, dependencies) - } catch (error) { - return { ok: false, failure: dependencyFailure(error) } - } + 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 } } @@ -295,8 +308,6 @@ export async function createWorkspaceSandbox( createdBy: 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 (isSandboxNameConflictError(error)) { return { ok: false, failure: { code: 'name_conflict', name } } } @@ -304,6 +315,7 @@ export async function createWorkspaceSandbox( } await scheduleSandboxBuild(spec) + logger.info('Created workspace sandbox', { workspaceId, sandboxId: id, language: spec.language }) return readBackOrFail(workspaceId, id) } @@ -345,9 +357,17 @@ export async function updateWorkspaceSandbox( return { ok: false, failure: { code: 'not_found', sandboxId } } } - const nextName = name ?? existing.name - if (name && name !== existing.name && (await isSandboxNameTaken(workspaceId, name, sandboxId))) { - return { ok: false, failure: { code: 'name_conflict', name } } + // 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 @@ -356,12 +376,9 @@ export async function updateWorkspaceSandbox( const nextLanguage = language ?? (existing.language as SandboxLanguage) const nextDependencies = dependencies ?? existing.dependencies ?? [] - let spec: SandboxSpecUpdate - try { - spec = buildSpecUpdate(nextLanguage, nextDependencies) - } catch (error) { - return { ok: false, failure: dependencyFailure(error) } - } + const built = buildSpecUpdate(nextLanguage, nextDependencies) + if (!built.ok) return built + const { spec } = built try { await db @@ -396,16 +413,14 @@ export async function updateWorkspaceSandbox( } /** - * Deletes a sandbox and releases its build. - * - * A block may still reference it. 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. + * Deletes a sandbox and releases its build. A block may still reference it; + * 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<{ ok: true; name: string } | { ok: false; failure: SandboxWriteFailure }> { +): Promise { const deleted = await db .delete(workspaceSandbox) .where(and(eq(workspaceSandbox.id, sandboxId), eq(workspaceSandbox.workspaceId, workspaceId))) From df6159dc166aa5e4db6fab3f54576cf3f68550f4 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Mon, 3 Aug 2026 20:55:17 -0700 Subject: [PATCH 3/3] refactor(copilot): align manage_sandbox handler with the manage_* family - Gate writes with copilotToolCanAdmin(context.userPermission), the admin sibling of the copilotToolCanWrite helper manage_custom_tool and manage_skill already use, instead of a second DB permission read. The tool now declares RequiredPermission "write", so the executor has already resolved the caller's permission by the time the handler runs. - Trim comments that restated the code, per the family's near-zero density. --- .../workspaces/[id]/sandboxes/authorize.ts | 4 --- .../lib/copilot/generated/tool-catalog-v1.ts | 1 + .../management/manage-sandbox.test.ts | 28 ++++++++----------- .../handlers/management/manage-sandbox.ts | 11 ++------ apps/sim/lib/copilot/tools/permissions.ts | 8 ++++++ .../workspace-sandboxes.test.ts | 6 ---- .../remote-sandbox/workspace-sandboxes.ts | 15 ++++------ 7 files changed, 29 insertions(+), 44 deletions(-) diff --git a/apps/sim/app/api/workspaces/[id]/sandboxes/authorize.ts b/apps/sim/app/api/workspaces/[id]/sandboxes/authorize.ts index 57f1057b275..59492474218 100644 --- a/apps/sim/app/api/workspaces/[id]/sandboxes/authorize.ts +++ b/apps/sim/app/api/workspaces/[id]/sandboxes/authorize.ts @@ -17,10 +17,6 @@ export interface SandboxMutationActor { } /** - * Maps a refused write onto the status code the editor expects. Shared by both - * route files so the create path and the edit/delete path cannot describe the - * same failure differently. - * * `invalid_dependencies` carries a line number per rejected row, which the * generic validation error does not — the editor marks those inline. */ diff --git a/apps/sim/lib/copilot/generated/tool-catalog-v1.ts b/apps/sim/lib/copilot/generated/tool-catalog-v1.ts index 88878fd4447..540296b602c 100644 --- a/apps/sim/lib/copilot/generated/tool-catalog-v1.ts +++ b/apps/sim/lib/copilot/generated/tool-catalog-v1.ts @@ -3646,6 +3646,7 @@ export const ManageSandbox: ToolCatalogEntry = { }, required: ['operation'], }, + requiredPermission: 'write', } export const ManageScheduledTask: ToolCatalogEntry = { 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 index 67bc3030438..a76d3076dcd 100644 --- a/apps/sim/lib/copilot/tools/handlers/management/manage-sandbox.test.ts +++ b/apps/sim/lib/copilot/tools/handlers/management/manage-sandbox.test.ts @@ -6,7 +6,6 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import type { ExecutionContext } from '@/lib/copilot/request/types' const { - getUserEntityPermissionsMock, hasWorkspaceSandboxAccessMock, enforceWorkspaceRateLimitMock, createWorkspaceSandboxMock, @@ -14,7 +13,6 @@ const { deleteWorkspaceSandboxMock, listWorkspaceSandboxesMock, } = vi.hoisted(() => ({ - getUserEntityPermissionsMock: vi.fn(), hasWorkspaceSandboxAccessMock: vi.fn(), enforceWorkspaceRateLimitMock: vi.fn(), createWorkspaceSandboxMock: vi.fn(), @@ -23,10 +21,6 @@ const { listWorkspaceSandboxesMock: vi.fn(), })) -vi.mock('@/lib/workspaces/permissions/utils', () => ({ - getUserEntityPermissions: getUserEntityPermissionsMock, -})) - vi.mock('@/lib/billing/core/subscription', () => ({ hasWorkspaceSandboxAccess: hasWorkspaceSandboxAccessMock, })) @@ -48,7 +42,12 @@ vi.mock('@/lib/execution/remote-sandbox/workspace-sandboxes', () => ({ import { executeManageSandbox } from '@/lib/copilot/tools/handlers/management/manage-sandbox' -const context = { userId: 'user-1', workflowId: 'wf-1', workspaceId: 'ws-1' } as ExecutionContext +const context = { + userId: 'user-1', + workflowId: 'wf-1', + workspaceId: 'ws-1', + userPermission: 'admin', +} as ExecutionContext const sandbox = { id: 'sb-1', @@ -67,7 +66,6 @@ const sandbox = { describe('manage_sandbox', () => { beforeEach(() => { vi.clearAllMocks() - getUserEntityPermissionsMock.mockResolvedValue('admin') hasWorkspaceSandboxAccessMock.mockResolvedValue(true) enforceWorkspaceRateLimitMock.mockResolvedValue(null) listWorkspaceSandboxesMock.mockResolvedValue([sandbox]) @@ -84,14 +82,14 @@ describe('manage_sandbox', () => { it('ignores a model-supplied workspaceId and uses the server context', async () => { await executeManageSandbox({ operation: 'list', workspaceId: 'other-ws' }, context) - expect(getUserEntityPermissionsMock).toHaveBeenCalledWith('user-1', 'workspace', 'ws-1') expect(listWorkspaceSandboxesMock).toHaveBeenCalledWith('ws-1') }) - it('lists with only read access, and does not spend the mutation budget', async () => { - getUserEntityPermissionsMock.mockResolvedValue('read') - - const result = await executeManageSandbox({ operation: 'list' }, context) + 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 @@ -102,11 +100,9 @@ describe('manage_sandbox', () => { }) it.each(['add', 'edit', 'delete'])('requires workspace admin to %s', async (operation) => { - getUserEntityPermissionsMock.mockResolvedValue('write') - const result = await executeManageSandbox( { operation, name: 'x', language: 'python', sandboxId: 'sb-1' }, - context + { ...context, userPermission: 'write' } as ExecutionContext ) expect(result.success).toBe(false) diff --git a/apps/sim/lib/copilot/tools/handlers/management/manage-sandbox.ts b/apps/sim/lib/copilot/tools/handlers/management/manage-sandbox.ts index 780535e8f5c..5db2b1b107e 100644 --- a/apps/sim/lib/copilot/tools/handlers/management/manage-sandbox.ts +++ b/apps/sim/lib/copilot/tools/handlers/management/manage-sandbox.ts @@ -2,6 +2,7 @@ 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, @@ -19,7 +20,6 @@ import { type SandboxWriteFailure, updateWorkspaceSandbox, } from '@/lib/execution/remote-sandbox/workspace-sandboxes' -import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils' const logger = createLogger('CopilotToolExecutor') @@ -69,7 +69,7 @@ function isStringArray(value: unknown): value is 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. `list` needs only read, matching GET. + * not create in Settings > Sandboxes. */ export async function executeManageSandbox( rawParams: Record, @@ -91,13 +91,8 @@ export async function executeManageSandbox( const isWrite = WRITE_OPERATIONS.includes(operation) try { - const permission = await getUserEntityPermissions(context.userId, 'workspace', workspaceId) - if (!permission) { - return { success: false, error: 'You do not have access to this workspace' } - } - if (isWrite) { - if (permission !== 'admin') { + if (!copilotToolCanAdmin(context.userPermission)) { return { success: false, error: SANDBOX_ADMIN_REQUIRED } } if (!(await hasWorkspaceSandboxAccess(workspaceId))) { 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/execution/remote-sandbox/workspace-sandboxes.test.ts b/apps/sim/lib/execution/remote-sandbox/workspace-sandboxes.test.ts index c0c8aa93d73..d10d8817f46 100644 --- a/apps/sim/lib/execution/remote-sandbox/workspace-sandboxes.test.ts +++ b/apps/sim/lib/execution/remote-sandbox/workspace-sandboxes.test.ts @@ -80,12 +80,6 @@ describe('workspace sandbox operations', () => { ) }) - /** - * The regression this guards: `nextName = name ?? existing.name` treated a - * whitespace-only name as "supplied", so it trimmed to empty, skipped the - * conflict pre-check (falsy), and wrote an unnamed sandbox the UI cannot - * create and the user cannot select. - */ it('refuses a whitespace-only name on edit instead of writing it', async () => { queueTableRows(workspaceSandbox, [existingRow]) diff --git a/apps/sim/lib/execution/remote-sandbox/workspace-sandboxes.ts b/apps/sim/lib/execution/remote-sandbox/workspace-sandboxes.ts index ffe272816e0..296629439fe 100644 --- a/apps/sim/lib/execution/remote-sandbox/workspace-sandboxes.ts +++ b/apps/sim/lib/execution/remote-sandbox/workspace-sandboxes.ts @@ -228,7 +228,6 @@ function isSandboxNameConflictError(error: unknown): boolean { return message.includes(WORKSPACE_SANDBOX_NAME_INDEX) || message.includes('23505') } -/** Why a write was refused, rendered by each caller for its own surface. */ export type SandboxWriteFailure = | { code: 'invalid_name'; message: string } | { code: 'name_conflict'; name: string } @@ -267,17 +266,15 @@ async function readBackOrFail(workspaceId: string, sandboxId: string): Promise