From c3bf218a1d7dd670d1f7b3ffed25ca5498ac351a Mon Sep 17 00:00:00 2001 From: Harold Kim Date: Thu, 9 Jul 2026 09:37:57 +0000 Subject: [PATCH] fix: align environment delete cancellation guard Split !impact.canDelete into two separate guards: a static blocker check (managed_local, instance_default) that always rejects, and a runtime use check that only rejects without force=true. On a forced delete that has an active custom image session, cancel the session first; if cancellation leaves the session in a non-terminal state (starting, waiting_for_user, capturing), return 409 instead of deleting the row. Then release active leases before removing the environment. Co-Authored-By: Paperclip --- server/src/routes/environments.ts | 36 +++++++++++++++++++++++++++++-- 1 file changed, 34 insertions(+), 2 deletions(-) diff --git a/server/src/routes/environments.ts b/server/src/routes/environments.ts index 6ea542fdcde..c6375c5ad2e 100644 --- a/server/src/routes/environments.ts +++ b/server/src/routes/environments.ts @@ -12,6 +12,7 @@ import { redactEnvironmentCustomImageTemplate, startEnvironmentCustomImageSetupSessionSchema, type EnvironmentDeleteBlastRadius, + type EnvironmentCustomImageSetupSessionStatus, updateEnvironmentSchema, } from "@paperclipai/shared"; import { conflict, forbidden, unprocessable } from "../errors.js"; @@ -47,9 +48,13 @@ import { listReadyPluginEnvironmentDrivers } from "../services/plugin-environmen import { getConfiguredSecretProvider } from "../secrets/configured-provider.js"; import { assertBoardOrgAccess, getActorInfo } from "./authz.js"; import type { PluginWorkerManager } from "../services/plugin-worker-manager.js"; -import { environmentService } from "../services/environments.js"; +import { environmentService, ACTIVE_CUSTOM_IMAGE_SETUP_STATUSES } from "../services/environments.js"; import { executionWorkspaceService } from "../services/execution-workspaces.js"; +function isActiveCustomImageSetupStatus(status: EnvironmentCustomImageSetupSessionStatus): boolean { + return (ACTIVE_CUSTOM_IMAGE_SETUP_STATUSES as readonly string[]).includes(status); +} + export function environmentRoutes( db: Db, options: { pluginWorkerManager?: PluginWorkerManager } = {}, @@ -813,10 +818,37 @@ export function environmentRoutes( res.status(404).json({ error: "Environment not found" }); return; } - if (!impact.canDelete) { + + const hasStaticBlocker = impact.deleteBlockedReasons.some( + (r) => r === "managed_local" || r === "instance_default", + ); + if (hasStaticBlocker) { rejectEnvironmentDelete({ actor, environment: existing, impact }); } + const hasRuntimeBlocker = impact.deleteBlockedReasons.includes("active_runtime_use"); + const forceDelete = req.query.force === "true"; + if (hasRuntimeBlocker && !forceDelete) { + rejectEnvironmentDelete({ actor, environment: existing, impact }); + } + + if (hasRuntimeBlocker && forceDelete) { + const overview = await customImages.getOverview({ environmentId: existing.id }); + if (overview.activeSession) { + const cancelled = await customImages.cancelSetupSession({ + sessionId: overview.activeSession.id, + reason: "environment_force_delete", + }); + if (isActiveCustomImageSetupStatus(cancelled.status)) { + throw conflict( + "Active custom image setup session could not be cancelled. Retry or wait for setup to complete before deleting.", + { deleteBlockedReasons: ["active_runtime_use"] }, + ); + } + } + await svc.releaseActiveLeasesForEnvironment(existing.id); + } + const removed = await svc.removeIfDeletable(existing.id); if (!removed) { const latestImpact = await svc.getDeleteBlastRadius(existing.id);