Files
PaperClipAI/ui/src/api/environments.ts
T
Devin Foley c62bb4b16b feat: environment delete with agent reassignment and consented sandbox destroy (#12053)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work
> - Environments define where agent runs execute: local, SSH, or
provider sandboxes
> - Operators can create and edit environments, but the UI has no way to
delete one
> - The server already exposes `DELETE /environments/:id` and a
delete-blast-radius preflight, but no UI consumes them, and a delete
blocked by reusable sandbox leases gives the operator no path forward
> - This pull request adds the delete flow to the environment
configuration page: a preflight-driven modal that reassigns dependent
agents, names the workspaces that hold blocking sandbox leases, and can
destroy those sandboxes with explicit consent
> - The benefit is that operators can retire stale environments from the
UI without database surgery, and dependent agents move to a chosen
replacement instead of silently falling back

## Linked Issues or Issue Description

Refs #8554
Refs #11124

**Subsystem affected**

Environments (server routes, environment runtime service, and the
environment settings UI).

**Problem or motivation**

The environment configuration page has no delete control. The server
delete endpoint exists, but nothing in the UI calls it. When reusable
sandbox leases block a delete, the 409 error names no owner, so the
operator cannot find the blocking workspace. Agents that use the
environment as their default lose it silently through the FK `on delete
set null`.

**Proposed solution**

Add a delete button with a confirmation modal on the environment edit
page. The modal reads the delete-blast-radius preflight. It offers a
dropdown to reassign dependent agents to another environment before the
delete. It lists each workspace that holds a blocking reusable sandbox
lease, with a link. When those leases are the only blocker, the confirm
button destroys the sandboxes inline
(`?destroyReusableSandboxLeases=true`) and then deletes. A failed
teardown falls back to `pending_cleanup` for the sweep, so no sandbox is
orphaned.

## What Changed

- `ui/src/pages/CompanyEnvironments.tsx`: delete button on the edit page
header, confirmation modal with agent reassignment select, lease-holder
list, impact notes, and a consent-labeled destroy-and-delete action
- `ui/src/api/environments.ts`: `deleteBlastRadius` and `remove` client
methods; `remove` takes an optional `destroyReusableSandboxLeases` flag
- `server/src/routes/environments.ts`: `DELETE /environments/:id`
accepts `?destroyReusableSandboxLeases=true`; it destroys the
environment's reusable sandbox leases first, but only when those leases
are the sole delete blocker, then re-checks the blast radius before it
deletes
- `server/src/services/environment-runtime.ts`: new
`destroyReusableSandboxLeasesForEnvironment` — destroys every reusable
sandbox lease an environment still owns while the environment config
(provider credentials) is still available
- `server/src/services/environments.ts`: the delete blast radius now
returns `reusableSandboxLeaseHolders` (lease id, workspace, issue) so
clients can name what blocks a delete
- `packages/shared/src/types/environment.ts`:
`EnvironmentDeleteReusableLeaseHolder` type on the blast radius
- Tests: route gating for the consent flag (destroy runs, mixed-blocker
rejection, surviving-lease rejection), runtime destroy scoped to an
environment, blast-radius holder join, and UI tests for the reassignment
flow, holder links, and the consent button

## Verification

- `npx vitest run server/src/__tests__/environment-routes.test.ts
server/src/__tests__/environment-service.test.ts
server/src/__tests__/environment-runtime.test.ts
ui/src/pages/CompanyEnvironments.test.tsx`
- Manual: open Settings → Environments → edit an environment. The trash
icon opens the modal. With agents on the environment, pick a
reassignment target and confirm; agents move and the environment
deletes. With reusable sandbox leases, the modal names the holding
workspaces and the confirm button reads "Destroy N sandboxes and
delete".

## Risks

- The consented path destroys provider sandboxes. It runs only when
reusable leases are the sole blocker, so a delete that would still be
rejected never destroys anything. A failed teardown routes to
`pending_cleanup` and the delete stays blocked until the sweep resolves
it.
- Agent reassignment issues one PATCH per agent from the client. A
mid-sequence failure leaves some agents reassigned; the reassignments
are valid on their own and the UI refreshes to the actual state.
- Hard blockers (managed local, instance default, pending cleanup) keep
the existing 409 behavior and disable the confirm button.

## Model Used

- Claude (Anthropic) — Fable 5, model id `claude-fable-5`, extended
thinking, agentic tool use via Claude Code CLI.

## 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. `docs/...`, `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
- [x] I will address all Greptile and reviewer comments before
requesting merge
2026-08-23 16:53:17 -07:00

233 lines
9.0 KiB
TypeScript

import type {
CancelEnvironmentCustomImageSetupSession,
Environment,
EnvironmentCapabilities,
EnvironmentDeleteBlastRadius,
EnvironmentLease,
EnvironmentProbeResult,
EnvironmentCustomImageSetupSession,
EnvironmentCustomImageTemplate,
EnvironmentCustomImageTerminalSessionToken,
FinishEnvironmentCustomImageSetupSession,
StartEnvironmentCustomImageSetupSession,
CreateEnvironmentCustomImageTerminalSessionToken,
} from "@paperclipai/shared";
import { api } from "./client";
export interface EnvironmentCustomImageOverview {
activeTemplate: EnvironmentCustomImageTemplate | null;
/**
* `false` means the environment config changed since capture and runs fall
* back to the base image until a new image is captured. `null` when unknown.
*/
activeTemplateMatchesConfig?: boolean | null;
/**
* Boot-relevant drift attribution for the active template. It names the
* classification and the drifted paths with their `from`/`to` values, so the
* banner can name the changed field. `null` or absent when there is no active
* template or the driver is not `sandbox`.
*/
activeTemplateDrift?: EnvironmentCustomImageActiveTemplateDrift | null;
activeSession: EnvironmentCustomImageSetupSession | null;
latestSession: EnvironmentCustomImageSetupSession | null;
}
export interface EnvironmentCustomImageActiveTemplateDrift {
classification: EnvironmentCustomImageRelinkClassification;
driftedPaths: EnvironmentCustomImageDriftedPath[];
}
export type EnvironmentCustomImageReconciliation =
| { action: "relinked"; template: EnvironmentCustomImageTemplate }
| { action: "detached"; template: EnvironmentCustomImageTemplate };
export type EnvironmentUpdateResult = Environment & {
customImageReconciliation?: EnvironmentCustomImageReconciliation;
};
export interface EnvironmentCustomImageConnectionPayload {
type: string;
command?: string | null;
token?: string | null;
expiresAt?: string | null;
metadata?: Record<string, unknown> | null;
}
export interface EnvironmentCustomImageSetupSessionResult {
session: EnvironmentCustomImageSetupSession;
connectionPayload: EnvironmentCustomImageConnectionPayload | null;
}
export interface EnvironmentCustomImageFinishResult extends EnvironmentCustomImageSetupSessionResult {
template: EnvironmentCustomImageTemplate;
}
export interface EnvironmentCustomImageRollbackResult {
activeTemplate: EnvironmentCustomImageTemplate;
supersededTemplate: EnvironmentCustomImageTemplate;
}
export type EnvironmentCustomImageRelinkClassification =
| "knob_only"
| "boot_source_drift"
| "unclassified";
export interface EnvironmentCustomImageRelinkResult {
template: EnvironmentCustomImageTemplate;
classification: EnvironmentCustomImageRelinkClassification;
}
export interface EnvironmentCustomImageDriftedPath {
path: string;
from?: unknown;
to?: unknown;
}
/**
* The 409 conflict body a relink returns when the server cannot re-stamp without
* an operator confirmation. `driftedPaths` carries `from`/`to` only for paths
* that passed the secret containment check; excluded paths carry the name only.
*/
export interface EnvironmentCustomImageRelinkConflict {
classification: Exclude<EnvironmentCustomImageRelinkClassification, "knob_only">;
driftedPaths: EnvironmentCustomImageDriftedPath[];
}
function companyIdQuery(companyId: string): string {
return `companyId=${encodeURIComponent(companyId)}`;
}
export interface EnvironmentSecretRefDescriptor {
configPath: string;
secretId: string;
name: string;
status: string;
companyId: string;
companyName: string | null;
}
export const environmentsApi = {
list: (companyId: string) => api.get<Environment[]>(`/companies/${companyId}/environments`),
capabilities: (companyId: string) =>
api.get<EnvironmentCapabilities>(`/companies/${companyId}/environments/capabilities`),
lease: (leaseId: string) => api.get<EnvironmentLease>(`/environment-leases/${leaseId}`),
secretRefs: (environmentId: string) =>
api.get<{ refs: EnvironmentSecretRefDescriptor[] }>(`/environments/${environmentId}/secret-refs`),
deleteBlastRadius: (environmentId: string) =>
api.get<EnvironmentDeleteBlastRadius>(`/environments/${environmentId}/delete-blast-radius`),
// The flag consents to destroying the environment's reusable sandbox leases
// inline so the delete can proceed; without it the server rejects with 409
// while such leases exist.
remove: (environmentId: string, options: { destroyReusableSandboxLeases?: boolean } = {}) =>
api.delete<Environment & { destroyedReusableSandboxLeaseCount?: number }>(
options.destroyReusableSandboxLeases
? `/environments/${environmentId}?destroyReusableSandboxLeases=true`
: `/environments/${environmentId}`,
),
create: (companyId: string, body: {
name: string;
description?: string | null;
driver: "local" | "ssh" | "sandbox" | "plugin";
config?: Record<string, unknown>;
metadata?: Record<string, unknown> | null;
}) => api.post<Environment>(`/companies/${companyId}/environments`, body),
update: (environmentId: string, body: {
name?: string;
description?: string | null;
driver?: "local" | "ssh" | "sandbox" | "plugin";
status?: "active" | "archived";
config?: Record<string, unknown>;
// The only field accepted on platform-managed environments (the server
// write floor admits envVars-only patches there).
envVars?: Environment["envVars"];
metadata?: Record<string, unknown> | null;
// Secret-context company for env var / config writes. Without it the
// server can only infer a company from existing bindings or a
// single-membership actor, and fails closed otherwise — a fresh
// environment with no bindings needs the explicit context.
}, companyId?: string | null) =>
api.patch<EnvironmentUpdateResult>(
companyId
? `/environments/${environmentId}?${companyIdQuery(companyId)}`
: `/environments/${environmentId}`,
body,
),
probe: (environmentId: string, companyId?: string | null) =>
api.post<EnvironmentProbeResult>(
companyId
? `/environments/${environmentId}/probe?${companyIdQuery(companyId)}`
: `/environments/${environmentId}/probe`,
{},
),
probeConfig: (companyId: string, body: {
name?: string;
driver: "local" | "ssh" | "sandbox" | "plugin";
description?: string | null;
config?: Record<string, unknown>;
metadata?: Record<string, unknown> | null;
}) => api.post<EnvironmentProbeResult>(`/companies/${companyId}/environments/probe-config`, body),
customImageTemplate: (environmentId: string, companyId: string) =>
api.get<EnvironmentCustomImageOverview>(
`/environments/${environmentId}/custom-image-template?${companyIdQuery(companyId)}`,
),
startCustomImageSetupSession: (
environmentId: string,
companyId: string,
body: StartEnvironmentCustomImageSetupSession = {},
) =>
api.post<EnvironmentCustomImageSetupSessionResult>(
`/environments/${environmentId}/custom-image-setup-sessions?${companyIdQuery(companyId)}`,
body,
),
customImageSetupSession: (sessionId: string) =>
api.get<EnvironmentCustomImageSetupSessionResult>(
`/environment-custom-image-setup-sessions/${sessionId}`,
),
createCustomImageTerminalSessionToken: (
sessionId: string,
body: CreateEnvironmentCustomImageTerminalSessionToken = {},
) =>
api.post<EnvironmentCustomImageTerminalSessionToken>(
`/environment-custom-image-setup-sessions/${sessionId}/terminal-session-token`,
body,
),
finishCustomImageSetupSession: (
sessionId: string,
body: FinishEnvironmentCustomImageSetupSession = {},
) =>
api.post<EnvironmentCustomImageFinishResult>(
`/environment-custom-image-setup-sessions/${sessionId}/finish`,
body,
),
cancelCustomImageSetupSession: (
sessionId: string,
body: CancelEnvironmentCustomImageSetupSession = {},
) =>
api.post<EnvironmentCustomImageSetupSession>(
`/environment-custom-image-setup-sessions/${sessionId}/cancel`,
body,
),
rollbackCustomImageTemplate: (environmentId: string, companyId: string) =>
api.post<EnvironmentCustomImageRollbackResult>(
`/environments/${environmentId}/custom-image-template/rollback?${companyIdQuery(companyId)}`,
{},
),
relinkCustomImageTemplate: (
environmentId: string,
companyId: string,
options: { confirmBootSourceDrift?: boolean } = {},
) =>
api.post<EnvironmentCustomImageRelinkResult>(
`/environments/${environmentId}/custom-image-template/relink?${companyIdQuery(companyId)}`,
{ confirmBootSourceDrift: options.confirmBootSourceDrift === true },
),
disableCustomImageTemplate: (
environmentId: string,
companyId: string,
options: { deleteProviderTemplate?: boolean } = {},
) =>
api.delete<EnvironmentCustomImageTemplate>(
`/environments/${environmentId}/custom-image-template?${companyIdQuery(companyId)}&deleteProviderTemplate=${options.deleteProviderTemplate === true ? "true" : "false"}`,
),
};