Harden environment deletion and expose delete blast radius (#9250)

## Thinking Path

> - Paperclip manages AI agents that each have an associated execution
environment (local, Kubernetes, etc.)
> - Instance administrators can create and delete environments;
currently the DELETE endpoint has no protection against deleting managed
or in-use environments
> - Deleting the managed local environment or the instance-default
environment would break all agents using those environments with no path
to recovery
> - The endpoint also suffered a TOCTOU race: a check-then-delete
pattern allowed the managed-local or default guard to pass if the
environment's role changed between the read and the delete
> - This pull request adds a blast-radius read endpoint so admins can
preview impact, hard-blocks the dangerous deletes atomically, cleans up
all dependent references after a valid delete, and fixes a concurrent
creation race in ensureLocalEnvironment

## Linked Issues or Issue Description

Fixes #9251

## What Changed

- **New endpoint** `GET /api/environments/:id/delete-blast-radius`
(instance-admin gated): returns reference counts (agent defaults,
workspace selections, issue selections, project selections, secret
bindings, active leases, active setup sessions) and blocking reasons —
no config, env-var values, or secret data returned.
- **Atomic delete guard** `environmentService.removeIfDeletable(id)`:
performs the DELETE with an inline `WHERE driver != 'local' AND NOT
EXISTS (instanceSettings where defaultEnvironmentId = id)` predicate,
eliminating the TOCTOU race between the app-level check and the DB
write.
- **Route hardening**: `DELETE /environments/:id` now calls
`getDeleteBlastRadius` first (app-level check + logging), then calls
`removeIfDeletable` (atomic guard). If the atomic guard returns null the
route fetches a fresh blast-radius snapshot and rejects with a 409
Conflict carrying `deleteBlockedReasons`.
- **Reference cleanup on valid delete**: after a successful delete, the
route clears environment selections on all company execution workspaces,
issues, and projects; syncs env-var secret bindings to `{}` (removing
bindings for the deleted environment); syncs config secret refs to `[]`
for the environment target; and removes the SSH private-key secret if
one was stored.
- **Race fix in `ensureLocalEnvironment`**: the insert-or-nothing path
now catches a `environments_name_idx` unique-constraint violation and
falls through to the existing SELECT, treating the name conflict as
idempotent.
- **Shared types**: `EnvironmentDeleteBlastRadius` and
`EnvironmentDeleteBlockedReason` exported from `@paperclipai/shared`.
- **OpenAPI**: registers the new blast-radius endpoint; updates the
delete-environment response schema to document 403/404/409.
- **Tests**: 56 existing environment-route and service tests continue to
pass; new service-level regression tests assert the atomic guard rejects
`local`-driver environments and instance-default environments and
succeeds for deletable ones.

## Verification

```
corepack pnpm exec vitest run \
  server/src/__tests__/environment-routes.test.ts \
  server/src/__tests__/environment-service.test.ts
# 56 tests, all passing
corepack pnpm --filter @paperclipai/shared typecheck
node scripts/ensure-plugin-build-deps.mjs
cd server && ../node_modules/.bin/tsc --noEmit
```

## Risks

- **Blast-radius endpoint auth**: guarded by
`assertCanAccessInstanceEnvironments`, the same gate as the existing
environment-list and delete routes. Non-admin callers receive 401/403
before any data is returned.
- **Atomic guard may reject a delete that the app-level check passed**:
this is intentional — it means the environment became protected between
the read and the write. The caller receives a fresh blast-radius
snapshot explaining why.
- **Secret cleanup ordering**: cleanup runs after the atomic DELETE
succeeds, in parallel across companies. If cleanup partially fails the
environment row is already gone; partial-cleanup state is recoverable by
re-running the sync operations. Risk: low — these are idempotent
upsert/sync operations.
- **ensureLocalEnvironment race fix**: swapping a unique-constraint
error for an idempotent SELECT adds one extra query on the conflict
path. This path is rare (only fires during concurrent boot) and is
significantly safer than the previous behavior.
- **No migration**: all changes are application-level; no schema changes
required.

## Model Used

- Provider: Anthropic
- Model: claude-sonnet-4-6 (Claude Sonnet 4.6)
- Context window: 200k tokens
- Mode: agentic tool use via Paperclip agent system (Claude Code)

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have searched GitHub for duplicate or related PRs and linked
them above
- [x] I have either (a) linked existing issues with `Fixes: #` / `Closes
#` / `Refs #` OR (b) described the issue in-PR following the relevant
issue template
- [x] I have not referenced internal/instance-local Paperclip issues or
links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip`
URLs)
- [x] My branch name describes the change (e.g. `fix/...`) and contains
no internal Paperclip ticket id or instance-derived details
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [ ] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [ ] All Paperclip CI gates are green
- [ ] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [ ] I will address all Greptile and reviewer comments before
requesting merge

---------

Co-authored-by: Priya Raman <priya.raman@paperclip.ing>
Co-authored-by: Paperclip <noreply@paperclip.ing>
Co-authored-by: Harold Kim <harold.kim@paperclip.ing>
This commit is contained in:
Nicky Leach 2026-07-08 22:04:22 -07:00 committed by GitHub
parent 1b16f9611c
commit 719da5f9b5
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
9 changed files with 856 additions and 33 deletions

View File

@ -6,6 +6,7 @@ export const API = {
agents: `${API_PREFIX}/agents`,
projects: `${API_PREFIX}/projects`,
environments: `${API_PREFIX}/environments`,
environmentDeleteBlastRadius: `${API_PREFIX}/environments/:id/delete-blast-radius`,
environmentCustomImageTemplate: `${API_PREFIX}/environments/:environmentId/custom-image-template`,
environmentCustomImageTemplateDisable: `${API_PREFIX}/environments/:environmentId/custom-image-template`,
environmentCustomImageTemplateRollback: `${API_PREFIX}/environments/:environmentId/custom-image-template/rollback`,

View File

@ -415,6 +415,8 @@ export {
export type {
Company,
Environment,
EnvironmentDeleteBlastRadius,
EnvironmentDeleteBlockedReason,
EnvironmentLease,
EnvironmentProbeResult,
FakeSandboxEnvironmentConfig,

View File

@ -71,6 +71,28 @@ export interface Environment {
updatedAt: Date;
}
export type EnvironmentDeleteBlockedReason = "managed_local" | "instance_default";
export interface EnvironmentDeleteBlastRadius {
environmentId: string;
canDelete: boolean;
deleteBlockedReasons: EnvironmentDeleteBlockedReason[];
staticReferences: {
isManagedLocal: boolean;
isInstanceDefault: boolean;
agentDefaultCount: number;
executionWorkspaceSelectionCount: number;
issueSelectionCount: number;
projectSelectionCount: number;
secretBindingCount: number;
};
activeRuntimeUse: {
activeLeaseCount: number;
activeCustomImageSetupSessionCount: number;
hasActiveRuntimeUse: boolean;
};
}
export interface EnvironmentLease {
id: string;
companyId: string;

View File

@ -1,6 +1,8 @@
export type { Company } from "./company.js";
export type {
Environment,
EnvironmentDeleteBlastRadius,
EnvironmentDeleteBlockedReason,
EnvironmentLease,
EnvironmentProbeResult,
FakeSandboxEnvironmentConfig,

View File

@ -17,10 +17,12 @@ const mockAgentService = vi.hoisted(() => ({
const mockIssueService = vi.hoisted(() => ({
getById: vi.fn(),
clearExecutionWorkspaceEnvironmentSelection: vi.fn(),
}));
const mockProjectService = vi.hoisted(() => ({
getById: vi.fn(),
clearExecutionWorkspaceEnvironmentSelection: vi.fn(),
}));
const mockInstanceSettingsService = vi.hoisted(() => ({
@ -32,6 +34,8 @@ const mockEnvironmentService = vi.hoisted(() => ({
getById: vi.fn(),
create: vi.fn(),
update: vi.fn(),
removeIfDeletable: vi.fn(),
getDeleteBlastRadius: vi.fn(),
listLeases: vi.fn(),
getLeaseById: vi.fn(),
}));
@ -70,7 +74,9 @@ const mockGetPluginEnvironmentInteractiveSetup = vi.hoisted(() => vi.fn());
const mockCapturePluginEnvironmentTemplate = vi.hoisted(() => vi.fn());
const mockCancelPluginEnvironmentInteractiveSetup = vi.hoisted(() => vi.fn());
const mockDeletePluginEnvironmentTemplate = vi.hoisted(() => vi.fn());
const mockExecutionWorkspaceService = vi.hoisted(() => ({}));
const mockExecutionWorkspaceService = vi.hoisted(() => ({
clearEnvironmentSelection: vi.fn(),
}));
vi.mock("../services/index.js", () => ({
issueService: () => mockIssueService,
@ -126,6 +132,46 @@ function createEnvironment() {
};
}
function createDeleteBlastRadius(overrides: Partial<{
isManagedLocal: boolean;
isInstanceDefault: boolean;
agentDefaultCount: number;
executionWorkspaceSelectionCount: number;
issueSelectionCount: number;
projectSelectionCount: number;
secretBindingCount: number;
activeLeaseCount: number;
activeCustomImageSetupSessionCount: number;
}> = {}) {
const staticReferences = {
isManagedLocal: overrides.isManagedLocal ?? false,
isInstanceDefault: overrides.isInstanceDefault ?? false,
agentDefaultCount: overrides.agentDefaultCount ?? 0,
executionWorkspaceSelectionCount: overrides.executionWorkspaceSelectionCount ?? 0,
issueSelectionCount: overrides.issueSelectionCount ?? 0,
projectSelectionCount: overrides.projectSelectionCount ?? 0,
secretBindingCount: overrides.secretBindingCount ?? 0,
};
const activeRuntimeUse = {
activeLeaseCount: overrides.activeLeaseCount ?? 0,
activeCustomImageSetupSessionCount: overrides.activeCustomImageSetupSessionCount ?? 0,
hasActiveRuntimeUse:
(overrides.activeLeaseCount ?? 0) > 0
|| (overrides.activeCustomImageSetupSessionCount ?? 0) > 0,
};
const deleteBlockedReasons = [
...(staticReferences.isManagedLocal ? ["managed_local" as const] : []),
...(staticReferences.isInstanceDefault ? ["instance_default" as const] : []),
];
return {
environmentId: "env-1",
canDelete: deleteBlockedReasons.length === 0,
deleteBlockedReasons,
staticReferences,
activeRuntimeUse,
};
}
let server: Server | null = null;
let currentActor: Record<string, unknown> = {
type: "board",
@ -178,15 +224,20 @@ describe("environment routes", () => {
mockAccessService.decide.mockReset();
mockAgentService.getById.mockReset();
mockIssueService.getById.mockReset();
mockIssueService.clearExecutionWorkspaceEnvironmentSelection.mockReset();
mockProjectService.getById.mockReset();
mockProjectService.clearExecutionWorkspaceEnvironmentSelection.mockReset();
mockInstanceSettingsService.listCompanyIds.mockReset();
mockEnvironmentService.list.mockReset();
mockEnvironmentService.list.mockResolvedValue([]);
mockEnvironmentService.getById.mockReset();
mockEnvironmentService.create.mockReset();
mockEnvironmentService.update.mockReset();
mockEnvironmentService.removeIfDeletable.mockReset();
mockEnvironmentService.getDeleteBlastRadius.mockReset();
mockEnvironmentService.listLeases.mockReset();
mockEnvironmentService.getLeaseById.mockReset();
mockExecutionWorkspaceService.clearEnvironmentSelection.mockReset();
Object.values(mockEnvironmentCustomImageService).forEach((mock) => mock.mockReset());
mockEnvironmentCustomImageService.getOverview.mockResolvedValue({
activeTemplate: null,
@ -209,6 +260,9 @@ describe("environment routes", () => {
id: "11111111-1111-1111-1111-111111111111",
});
mockInstanceSettingsService.listCompanyIds.mockResolvedValue(["company-1"]);
mockIssueService.clearExecutionWorkspaceEnvironmentSelection.mockResolvedValue(0);
mockProjectService.clearExecutionWorkspaceEnvironmentSelection.mockResolvedValue(0);
mockExecutionWorkspaceService.clearEnvironmentSelection.mockResolvedValue(0);
mockSecretService.normalizeEnvBindingsForPersistence.mockImplementation(async (_companyId, env) => env ?? {});
mockSecretService.listBindingCompanyIdsForTarget.mockResolvedValue([]);
mockSecretService.syncEnvBindingsForTarget.mockResolvedValue([]);
@ -329,6 +383,79 @@ describe("environment routes", () => {
});
});
it("rejects non-admin blast-radius reads for instance-scoped environments", async () => {
const app = createApp({
type: "board",
userId: "user-2",
source: "session",
companyIds: ["company-1"],
memberships: [{ companyId: "company-1", status: "active", membershipRole: "member" }],
isInstanceAdmin: false,
});
const res = await request(app).get("/api/environments/env-1/delete-blast-radius");
expect(res.status).toBe(403);
expect(mockEnvironmentService.getDeleteBlastRadius).not.toHaveBeenCalled();
});
it("returns delete blast radius counts for instance admins", async () => {
mockEnvironmentService.getDeleteBlastRadius.mockResolvedValue(createDeleteBlastRadius({
agentDefaultCount: 2,
secretBindingCount: 3,
activeLeaseCount: 1,
}));
const app = createApp({
type: "board",
userId: "admin-1",
source: "session",
companyIds: ["company-1"],
isInstanceAdmin: true,
});
const res = await request(app).get("/api/environments/env-1/delete-blast-radius");
expect(res.status).toBe(200);
expect(res.body).toEqual({
environmentId: "env-1",
canDelete: true,
deleteBlockedReasons: [],
staticReferences: {
isManagedLocal: false,
isInstanceDefault: false,
agentDefaultCount: 2,
executionWorkspaceSelectionCount: 0,
issueSelectionCount: 0,
projectSelectionCount: 0,
secretBindingCount: 3,
},
activeRuntimeUse: {
activeLeaseCount: 1,
activeCustomImageSetupSessionCount: 0,
hasActiveRuntimeUse: true,
},
});
expect(res.body).not.toHaveProperty("config");
expect(res.body).not.toHaveProperty("envVars");
expect(res.body).not.toHaveProperty("metadata");
});
it("returns 404 for missing delete blast radius targets", async () => {
mockEnvironmentService.getDeleteBlastRadius.mockResolvedValue(null);
const app = createApp({
type: "board",
userId: "admin-1",
source: "session",
companyIds: ["company-1"],
isInstanceAdmin: true,
});
const res = await request(app).get("/api/environments/missing/delete-blast-radius");
expect(res.status).toBe(404);
expect(res.body.error).toBe("Environment not found");
});
it("returns provider capabilities for the company", async () => {
const app = createApp({
type: "board",
@ -597,6 +724,129 @@ describe("environment routes", () => {
expect(mockEnvironmentService.create).not.toHaveBeenCalled();
});
it("rejects deleting the managed local environment", async () => {
const environment = createEnvironment();
mockEnvironmentService.getById.mockResolvedValue(environment);
mockEnvironmentService.getDeleteBlastRadius.mockResolvedValue(createDeleteBlastRadius({
isManagedLocal: true,
}));
const app = createApp({
type: "board",
userId: "admin-1",
source: "local_implicit",
});
const res = await request(app).delete("/api/environments/env-1");
expect(res.status).toBe(409);
expect(res.body.error).toBe("Cannot delete the managed local environment.");
expect(res.body.details).toEqual({ deleteBlockedReasons: ["managed_local"] });
expect(mockEnvironmentService.removeIfDeletable).not.toHaveBeenCalled();
expect(mockExecutionWorkspaceService.clearEnvironmentSelection).not.toHaveBeenCalled();
});
it("rejects deleting the current instance default environment", async () => {
const environment = {
...createEnvironment(),
driver: "ssh" as const,
name: "SSH Fixture",
config: {
host: "ssh.example.test",
port: 22,
username: "ssh-user",
remoteWorkspacePath: "/srv/paperclip/workspace",
privateKey: null,
privateKeySecretRef: null,
knownHosts: null,
strictHostKeyChecking: true,
},
};
mockEnvironmentService.getById.mockResolvedValue(environment);
mockEnvironmentService.getDeleteBlastRadius.mockResolvedValue(createDeleteBlastRadius({
isInstanceDefault: true,
}));
const app = createApp({
type: "board",
userId: "admin-1",
source: "local_implicit",
});
const res = await request(app).delete("/api/environments/env-1");
expect(res.status).toBe(409);
expect(res.body.error).toBe(
"Cannot delete the current instance default environment. Set a new default environment before deleting this one.",
);
expect(res.body.details).toEqual({ deleteBlockedReasons: ["instance_default"] });
expect(mockEnvironmentService.removeIfDeletable).not.toHaveBeenCalled();
});
it("clears environment selections and secret bindings across all companies when deleting an environment", async () => {
const environment = {
...createEnvironment(),
id: "env-ssh",
name: "SSH Fixture",
driver: "ssh" as const,
config: {
host: "ssh.example.test",
port: 22,
username: "ssh-user",
remoteWorkspacePath: "/srv/paperclip/workspace",
privateKey: null,
privateKeySecretRef: {
type: "secret_ref",
secretId: "11111111-1111-1111-1111-111111111111",
version: "latest",
},
knownHosts: null,
strictHostKeyChecking: true,
},
};
mockEnvironmentService.getById.mockResolvedValue(environment);
mockEnvironmentService.getDeleteBlastRadius.mockResolvedValue(createDeleteBlastRadius());
mockEnvironmentService.removeIfDeletable.mockResolvedValue(environment);
mockInstanceSettingsService.listCompanyIds.mockResolvedValue(["company-1", "company-2"]);
const app = createApp({
type: "board",
userId: "admin-1",
source: "local_implicit",
});
const res = await request(app).delete("/api/environments/env-ssh");
expect(res.status).toBe(200);
expect(mockEnvironmentService.removeIfDeletable).toHaveBeenCalledWith("env-ssh");
for (const companyId of ["company-1", "company-2"]) {
expect(mockExecutionWorkspaceService.clearEnvironmentSelection)
.toHaveBeenCalledWith(companyId, "env-ssh");
expect(mockIssueService.clearExecutionWorkspaceEnvironmentSelection)
.toHaveBeenCalledWith(companyId, "env-ssh");
expect(mockProjectService.clearExecutionWorkspaceEnvironmentSelection)
.toHaveBeenCalledWith(companyId, "env-ssh");
expect(mockSecretService.syncEnvBindingsForTarget).toHaveBeenCalledWith(
companyId,
{ targetType: "environment", targetId: "env-ssh" },
{},
);
expect(mockSecretService.syncSecretRefsForTarget).toHaveBeenCalledWith(
companyId,
{ targetType: "environment", targetId: "env-ssh" },
[],
{ replaceAll: true },
);
}
expect(mockSecretService.remove).toHaveBeenCalledWith("11111111-1111-1111-1111-111111111111");
expect(mockLogActivity).toHaveBeenCalledWith(
expect.anything(),
expect.objectContaining({
companyId: "company-1",
action: "environment.deleted",
entityType: "environment",
entityId: "env-ssh",
}),
);
});
it("rejects invalid SSH config on create", async () => {
const app = createApp({
type: "board",

View File

@ -1,7 +1,21 @@
import { randomUUID } from "node:crypto";
import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest";
import { and, eq } from "drizzle-orm";
import { agents, companies, createDb, environmentLeases, environments, heartbeatRuns } from "@paperclipai/db";
import {
agents,
companies,
companySecretBindings,
companySecrets,
createDb,
environmentCustomImageSetupSessions,
environmentLeases,
environments,
executionWorkspaces,
heartbeatRuns,
instanceSettings,
issues,
projects,
} from "@paperclipai/db";
import {
getEmbeddedPostgresTestSupport,
startEmbeddedPostgresTestDatabase,
@ -30,10 +44,17 @@ describeEmbeddedPostgres("environmentService leases", () => {
});
afterEach(async () => {
await db.delete(companySecretBindings);
await db.delete(environmentCustomImageSetupSessions);
await db.delete(environmentLeases);
await db.delete(heartbeatRuns);
await db.delete(issues);
await db.delete(executionWorkspaces);
await db.delete(projects);
await db.delete(agents);
await db.delete(instanceSettings);
await db.delete(environments);
await db.delete(companySecrets);
await db.delete(companies);
});
@ -146,6 +167,312 @@ describeEmbeddedPostgres("environmentService leases", () => {
expect(stillActive.map((lease) => lease.id)).toEqual([otherLease.id]);
});
it("aggregates delete blast radius counts into static and active tiers", async () => {
const companyId = randomUUID();
const otherCompanyId = randomUUID();
const environmentId = randomUUID();
const otherEnvironmentId = randomUUID();
const projectId = randomUUID();
const issueId = randomUUID();
const workspaceId = randomUUID();
const secretId = randomUUID();
const otherSecretId = randomUUID();
const now = new Date();
await db.insert(companies).values([
{
id: companyId,
name: "Acme",
status: "active",
issuePrefix: "ACM",
createdAt: now,
updatedAt: now,
},
{
id: otherCompanyId,
name: "Other Co",
status: "active",
issuePrefix: "OTH",
createdAt: now,
updatedAt: now,
},
]);
await db.insert(environments).values([
{
id: environmentId,
name: "Shared SSH",
driver: "ssh",
status: "active",
config: {
host: "fixture.example.test",
port: 22,
username: "fixture",
remoteWorkspacePath: "/srv/paperclip",
},
createdAt: now,
updatedAt: now,
},
{
id: otherEnvironmentId,
name: "Other SSH",
driver: "ssh",
status: "active",
config: {
host: "other.example.test",
port: 22,
username: "fixture",
remoteWorkspacePath: "/srv/paperclip",
},
createdAt: now,
updatedAt: now,
},
]);
await db.insert(instanceSettings).values({
singletonKey: "default",
defaultEnvironmentId: environmentId,
general: {},
experimental: {},
createdAt: now,
updatedAt: now,
});
await db.insert(agents).values([
{
companyId,
name: "CodexCoder",
role: "engineer",
status: "active",
adapterType: "codex_local",
adapterConfig: {},
runtimeConfig: {},
defaultEnvironmentId: environmentId,
permissions: {},
createdAt: now,
updatedAt: now,
},
{
companyId,
name: "OtherCoder",
role: "engineer",
status: "active",
adapterType: "codex_local",
adapterConfig: {},
runtimeConfig: {},
defaultEnvironmentId: otherEnvironmentId,
permissions: {},
createdAt: now,
updatedAt: now,
},
]);
await db.insert(projects).values({
id: projectId,
companyId,
name: "Project",
status: "in_progress",
executionWorkspacePolicy: {
enabled: true,
defaultMode: "isolated_workspace",
environmentId,
},
createdAt: now,
updatedAt: now,
});
await db.insert(issues).values({
id: issueId,
companyId,
projectId,
title: "Issue",
status: "todo",
priority: "medium",
executionWorkspaceSettings: {
mode: "isolated_workspace",
environmentId,
},
createdAt: now,
updatedAt: now,
});
await db.insert(executionWorkspaces).values({
id: workspaceId,
companyId,
projectId,
sourceIssueId: issueId,
mode: "isolated_workspace",
strategyType: "git_worktree",
name: "Workspace",
status: "active",
providerType: "git_worktree",
metadata: {
config: {
environmentId,
},
},
createdAt: now,
updatedAt: now,
});
await db.insert(companySecrets).values([
{
id: secretId,
companyId,
key: "env-secret",
name: "Env Secret",
provider: "local_encrypted",
createdAt: now,
updatedAt: now,
},
{
id: otherSecretId,
companyId: otherCompanyId,
key: "other-env-secret",
name: "Other Env Secret",
provider: "local_encrypted",
createdAt: now,
updatedAt: now,
},
]);
await db.insert(companySecretBindings).values([
{
companyId,
secretId,
targetType: "environment",
targetId: environmentId,
configPath: "env.OPENAI_API_KEY",
createdAt: now,
updatedAt: now,
},
{
companyId: otherCompanyId,
secretId: otherSecretId,
targetType: "environment",
targetId: environmentId,
configPath: "env.ANTHROPIC_API_KEY",
createdAt: now,
updatedAt: now,
},
{
companyId,
secretId,
targetType: "agent",
targetId: "agent-1",
configPath: "env.OPENAI_API_KEY",
createdAt: now,
updatedAt: now,
},
]);
await svc.acquireLease({
companyId,
environmentId,
});
const releasedLease = await svc.acquireLease({
companyId,
environmentId,
});
await svc.releaseLease(releasedLease.id);
await db.insert(environmentCustomImageSetupSessions).values([
{
environmentId,
provider: "fake-plugin",
status: "waiting_for_user",
createdAt: now,
updatedAt: now,
},
]);
const impact = await svc.getDeleteBlastRadius(environmentId);
expect(impact).toEqual({
environmentId,
canDelete: false,
deleteBlockedReasons: ["instance_default"],
staticReferences: {
isManagedLocal: false,
isInstanceDefault: true,
agentDefaultCount: 1,
executionWorkspaceSelectionCount: 1,
issueSelectionCount: 1,
projectSelectionCount: 1,
secretBindingCount: 2,
},
activeRuntimeUse: {
activeLeaseCount: 1,
activeCustomImageSetupSessionCount: 1,
hasActiveRuntimeUse: true,
},
});
});
it("guards removeIfDeletable with atomic local/default predicates", async () => {
const localEnvId = randomUUID();
const defaultEnvId = randomUUID();
const deletableEnvId = randomUUID();
const now = new Date();
await db.insert(environments).values([
{
id: localEnvId,
name: "Local Guard",
driver: "local",
status: "active",
config: {},
createdAt: now,
updatedAt: now,
},
{
id: defaultEnvId,
name: "Default SSH Guard",
driver: "ssh",
status: "active",
config: {
host: "default.example.test",
port: 22,
username: "fixture",
remoteWorkspacePath: "/srv/paperclip",
},
createdAt: now,
updatedAt: now,
},
{
id: deletableEnvId,
name: "Deletable SSH Guard",
driver: "ssh",
status: "active",
config: {
host: "delete.example.test",
port: 22,
username: "fixture",
remoteWorkspacePath: "/srv/paperclip",
},
createdAt: now,
updatedAt: now,
},
]);
await db.insert(instanceSettings).values({
singletonKey: "default",
defaultEnvironmentId: defaultEnvId,
general: {},
experimental: {},
createdAt: now,
updatedAt: now,
});
const removedLocal = await svc.removeIfDeletable(localEnvId);
const localRows = await db.select().from(environments).where(eq(environments.id, localEnvId));
expect(removedLocal).toBeNull();
expect(localRows).toHaveLength(1);
expect(localRows[0]?.driver).toBe("local");
const removedDefault = await svc.removeIfDeletable(defaultEnvId);
const defaultRows = await db.select().from(environments).where(eq(environments.id, defaultEnvId));
expect(removedDefault).toBeNull();
expect(defaultRows).toHaveLength(1);
const removedDeletable = await svc.removeIfDeletable(deletableEnvId);
const deletedRows = await db.select().from(environments).where(eq(environments.id, deletableEnvId));
expect(removedDeletable?.id).toBe(deletableEnvId);
expect(deletedRows).toHaveLength(0);
});
it("creates and then reuses the default local environment for a company", async () => {
const companyId = randomUUID();
await db.insert(companies).values({

View File

@ -11,10 +11,12 @@ import {
redactEnvironmentCustomImageSetupSession,
redactEnvironmentCustomImageTemplate,
startEnvironmentCustomImageSetupSessionSchema,
type EnvironmentDeleteBlastRadius,
updateEnvironmentSchema,
} from "@paperclipai/shared";
import { conflict, forbidden, unprocessable } from "../errors.js";
import { validate } from "../middleware/validate.js";
import { logger } from "../middleware/logger.js";
import {
environmentCustomImageService,
issueService,
@ -270,6 +272,39 @@ export function environmentRoutes(
return details;
}
function environmentDeleteBlockMessage(impact: EnvironmentDeleteBlastRadius): string | null {
if (impact.staticReferences.isManagedLocal) {
return "Cannot delete the managed local environment.";
}
if (impact.staticReferences.isInstanceDefault) {
return "Cannot delete the current instance default environment. Set a new default environment before deleting this one.";
}
return null;
}
function rejectEnvironmentDelete(input: {
actor: ReturnType<typeof getActorInfo>;
environment: { id: string; driver: string };
impact: EnvironmentDeleteBlastRadius;
}): never {
const message =
environmentDeleteBlockMessage(input.impact)
?? "Environment delete is currently blocked. Refresh the environment and retry.";
logger.warn(
{
environmentId: input.environment.id,
environmentDriver: input.environment.driver,
deleteBlockedReasons: input.impact.deleteBlockedReasons,
actorType: input.actor.actorType,
actorId: input.actor.actorId,
agentId: input.actor.agentId,
runId: input.actor.runId,
},
"environment delete rejected by guard",
);
throw conflict(message, { deleteBlockedReasons: input.impact.deleteBlockedReasons });
}
function setupSessionActivityDetails(session: {
id: string;
environmentId: string;
@ -334,6 +369,16 @@ export function environmentRoutes(
res.json(rows.map((row) => presentEnvironmentForRead(req, row)));
});
router.get("/environments/:id/delete-blast-radius", async (req, res) => {
assertCanAccessInstanceEnvironments(req);
const impact = await svc.getDeleteBlastRadius(req.params.id as string);
if (!impact) {
res.status(404).json({ error: "Environment not found" });
return;
}
res.json(impact);
});
router.get("/companies/:companyId/environments/capabilities", async (req, res) => {
assertCanReadInstanceEnvironments(req);
const pluginDrivers = await listReadyPluginEnvironmentDrivers({
@ -762,24 +807,48 @@ export function environmentRoutes(
return;
}
assertCanAccessInstanceEnvironments(req);
const actor = getActorInfo(req);
const impact = await svc.getDeleteBlastRadius(existing.id);
if (!impact) {
res.status(404).json({ error: "Environment not found" });
return;
}
if (!impact.canDelete) {
rejectEnvironmentDelete({ actor, environment: existing, impact });
}
const removed = await svc.removeIfDeletable(existing.id);
if (!removed) {
const latestImpact = await svc.getDeleteBlastRadius(existing.id);
if (!latestImpact) {
res.status(404).json({ error: "Environment not found" });
return;
}
rejectEnvironmentDelete({ actor, environment: existing, impact: latestImpact });
}
const companyIds = await instanceSettings.listCompanyIds();
await Promise.all(
companyIds.flatMap((companyId) => [
executionWorkspaces.clearEnvironmentSelection(companyId, existing.id),
issues.clearExecutionWorkspaceEnvironmentSelection(companyId, existing.id),
projects.clearExecutionWorkspaceEnvironmentSelection(companyId, existing.id),
secrets.syncEnvBindingsForTarget(
companyId,
{ targetType: "environment", targetId: existing.id },
{},
),
secrets.syncSecretRefsForTarget(
companyId,
{ targetType: "environment", targetId: existing.id },
[],
{ replaceAll: true },
),
]),
);
const removed = await svc.remove(existing.id);
if (!removed) {
res.status(404).json({ error: "Environment not found" });
return;
}
const secretId = readSshEnvironmentPrivateKeySecretId(existing);
if (secretId) {
await secrets.remove(secretId);
}
const actor = getActorInfo(req);
await logInstanceEnvironmentActivity({
actor,
action: "environment.deleted",

View File

@ -3703,6 +3703,15 @@ registry.registerPath({
responses: { 200: r.ok(), 401: r.unauthorized, 404: r.notFound },
});
registry.registerPath({
method: "get",
path: "/api/environments/{id}/delete-blast-radius",
tags: ["environments"],
summary: "Get environment delete blast radius",
request: { params: z.object({ id: z.string() }) },
responses: { 200: r.ok(), 401: r.unauthorized, 403: r.forbidden, 404: r.notFound },
});
registry.registerPath({
method: "get",
path: "/api/environments/{id}/leases",
@ -3739,7 +3748,7 @@ registry.registerPath({
tags: ["environments"],
summary: "Delete an environment",
request: { params: z.object({ id: z.string() }) },
responses: { 200: r.ok(), 401: r.unauthorized },
responses: { 200: r.ok(), 401: r.unauthorized, 403: r.forbidden, 404: r.notFound, 409: r.conflict },
});
registry.registerPath({

View File

@ -1,6 +1,16 @@
import { and, desc, eq, sql } from "drizzle-orm";
import { and, desc, eq, inArray, ne, sql } from "drizzle-orm";
import type { Db } from "@paperclipai/db";
import { environmentLeases, environments } from "@paperclipai/db";
import {
agents,
companySecretBindings,
environmentCustomImageSetupSessions,
environmentLeases,
environments,
executionWorkspaces,
instanceSettings,
issues,
projects,
} from "@paperclipai/db";
import {
ENVIRONMENT_DRIVERS,
ENVIRONMENT_LEASE_CLEANUP_STATUSES,
@ -9,6 +19,8 @@ import {
ENVIRONMENT_STATUSES,
type CreateEnvironment,
type Environment,
type EnvironmentDeleteBlastRadius,
type EnvironmentDeleteBlockedReason,
type EnvironmentLease,
type EnvironmentLeaseCleanupStatus,
type EnvironmentLeasePolicy,
@ -30,6 +42,7 @@ const DEFAULT_KUBERNETES_ENVIRONMENT_DESCRIPTION =
const KUBERNETES_PROVIDER_KEY = "kubernetes";
/** Metadata marker for the company's managed-by-config Kubernetes sandbox environment. */
const KUBERNETES_MANAGED_MARKER = "managedKubernetesSandbox";
const ACTIVE_CUSTOM_IMAGE_SETUP_STATUSES = ["starting", "waiting_for_user", "capturing"] as const;
/**
* Configuration accepted by `ensureKubernetesEnvironment`. Mirrors the keys of
@ -163,6 +176,10 @@ function toEnvironmentLease(row: EnvironmentLeaseRow): EnvironmentLease {
};
}
function countFromRows(rows: Array<{ count: number | string | null | undefined }>): number {
return Number(rows[0]?.count ?? 0);
}
export function environmentService(db: Db) {
return {
list: async (
@ -197,28 +214,35 @@ export function environmentService(db: Db) {
ensureLocalEnvironment: async (_companyId?: string): Promise<Environment> => {
const now = new Date();
const row = await db
.insert(environments)
.values({
name: DEFAULT_LOCAL_ENVIRONMENT_NAME,
description: DEFAULT_LOCAL_ENVIRONMENT_DESCRIPTION,
driver: "local",
status: "active",
config: {},
envVars: {},
metadata: {
managedByPaperclip: true,
defaultForInstance: true,
},
createdAt: now,
updatedAt: now,
})
.onConflictDoNothing({
target: [environments.driver],
where: sql`${environments.driver} = 'local'`,
})
.returning()
.then((rows) => rows[0] ?? null);
const insert = () =>
db
.insert(environments)
.values({
name: DEFAULT_LOCAL_ENVIRONMENT_NAME,
description: DEFAULT_LOCAL_ENVIRONMENT_DESCRIPTION,
driver: "local",
status: "active",
config: {},
envVars: {},
metadata: {
managedByPaperclip: true,
defaultForInstance: true,
},
createdAt: now,
updatedAt: now,
})
.onConflictDoNothing({
target: [environments.driver],
where: sql`${environments.driver} = 'local'`,
})
.returning()
.then((rows) => rows[0] ?? null);
const row = await insert().catch((error: unknown) => {
if (hasConstraintName(error, "environments_name_idx")) {
return null;
}
throw error;
});
if (row) return toEnvironment(row);
const existing = await db
@ -438,6 +462,123 @@ export function environmentService(db: Db) {
return row ? toEnvironment(row) : null;
},
removeIfDeletable: async (id: string): Promise<Environment | null> => {
const row = await db
.delete(environments)
.where(
and(
eq(environments.id, id),
ne(environments.driver, "local"),
sql`not exists (
select 1 from ${instanceSettings}
where ${instanceSettings.defaultEnvironmentId} = ${environments.id}
)`,
),
)
.returning()
.then((rows) => rows[0] ?? null);
return row ? toEnvironment(row) : null;
},
getDeleteBlastRadius: async (id: string): Promise<EnvironmentDeleteBlastRadius | null> => {
const environment = await db
.select({
id: environments.id,
driver: environments.driver,
})
.from(environments)
.where(eq(environments.id, id))
.then((rows) => rows[0] ?? null);
if (!environment) return null;
const [
instanceDefaultRows,
agentDefaultRows,
executionWorkspaceRows,
issueRows,
projectRows,
secretBindingRows,
activeLeaseRows,
activeSetupRows,
] = await Promise.all([
db
.select({ count: sql<number>`count(*)::int` })
.from(instanceSettings)
.where(eq(instanceSettings.defaultEnvironmentId, id)),
db
.select({ count: sql<number>`count(*)::int` })
.from(agents)
.where(eq(agents.defaultEnvironmentId, id)),
db
.select({ count: sql<number>`count(*)::int` })
.from(executionWorkspaces)
.where(sql`${executionWorkspaces.metadata} -> 'config' ->> 'environmentId' = ${id}`),
db
.select({ count: sql<number>`count(*)::int` })
.from(issues)
.where(sql`${issues.executionWorkspaceSettings} ->> 'environmentId' = ${id}`),
db
.select({ count: sql<number>`count(*)::int` })
.from(projects)
.where(sql`${projects.executionWorkspacePolicy} ->> 'environmentId' = ${id}`),
db
.select({ count: sql<number>`count(*)::int` })
.from(companySecretBindings)
.where(
and(
eq(companySecretBindings.targetType, "environment"),
eq(companySecretBindings.targetId, id),
),
),
db
.select({ count: sql<number>`count(*)::int` })
.from(environmentLeases)
.where(
and(
eq(environmentLeases.environmentId, id),
eq(environmentLeases.status, "active"),
),
),
db
.select({ count: sql<number>`count(*)::int` })
.from(environmentCustomImageSetupSessions)
.where(
and(
eq(environmentCustomImageSetupSessions.environmentId, id),
inArray(environmentCustomImageSetupSessions.status, [...ACTIVE_CUSTOM_IMAGE_SETUP_STATUSES]),
),
),
]);
const isManagedLocal = environment.driver === "local";
const isInstanceDefault = countFromRows(instanceDefaultRows) > 0;
const deleteBlockedReasons: EnvironmentDeleteBlockedReason[] = [];
if (isManagedLocal) deleteBlockedReasons.push("managed_local");
if (isInstanceDefault) deleteBlockedReasons.push("instance_default");
const activeLeaseCount = countFromRows(activeLeaseRows);
const activeCustomImageSetupSessionCount = countFromRows(activeSetupRows);
return {
environmentId: id,
canDelete: deleteBlockedReasons.length === 0,
deleteBlockedReasons,
staticReferences: {
isManagedLocal,
isInstanceDefault,
agentDefaultCount: countFromRows(agentDefaultRows),
executionWorkspaceSelectionCount: countFromRows(executionWorkspaceRows),
issueSelectionCount: countFromRows(issueRows),
projectSelectionCount: countFromRows(projectRows),
secretBindingCount: countFromRows(secretBindingRows),
},
activeRuntimeUse: {
activeLeaseCount,
activeCustomImageSetupSessionCount,
hasActiveRuntimeUse: activeLeaseCount > 0 || activeCustomImageSetupSessionCount > 0,
},
};
},
listLeases: async (
environmentId: string,
filters: {