Files
PaperClipAI/ui/src/pages/CompanyEnvironments.tsx
T
Devin Foley 7b91fe9ea7 Hide host-path and execution-engine surfaces in managed-sandbox-only mode (#12293)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work
> - An instance can turn on the `enableManagedSandboxOnly` feature,
which hides the local environment and runs every agent in the
platform-managed environment
> - That feature already gated the environment pickers, the onboarding
wizard, and the server-side run selection, but many other screens still
showed absolute paths on the execution host and still let the user pick
an execution engine
> - On such an instance those controls name a filesystem the user cannot
reach; a path written there is stored and then ignored, which reads as a
broken control
> - This pull request hides the remaining host-path and execution-engine
surfaces behind the same feature, adds a server rule that refuses a
project-workspace path write while the feature is on, and closes a
related route gap in the isolated-workspace pages
> - The benefit is that a managed instance shows no host path and no
folder picker anywhere, and a write that carries a path now fails with a
clear message instead of being silently discarded

## Linked Issues or Issue Description

No public issue exists. The description below follows
`.github/ISSUE_TEMPLATE/enhancement.yml`.

**What existing behavior does this improve?**

The `enableManagedSandboxOnly` instance feature, and the UI surfaces
that show
a host filesystem path: project properties, the new-project dialog, the
project
workspace and execution workspace detail pages, the workspace and task
cards,
plugin local folders, and the agent configuration form with its
per-adapter
fields. It also improves route gating for `enableIsolatedWorkspaces`.

**Subsystem affected**

Cross-cutting (`ui/` and `server/`).

**Current behavior**

When `enableManagedSandboxOnly` is on, the local environment disappears
from the
environment pickers and the server refuses to run an agent on the local
host.
Everything else stays visible. A user still sees:

- the project "Local folder" row, its absolute path, and the
Set/Change/Clear buttons
- the "Local folder" field and its "Choose" folder picker in the
new-project dialog
- the "Local path" field and fact row on a project workspace
- the "Paths" and "Lifecycle commands" groups on an execution workspace
- the working directory on workspace cards, task properties, and runtime
service rows
- the plugin "Local folders" section
- "Working directory (deprecated)", "Command", "Execution engine",
"ACP server command", "ACP state directory", and "Agent instructions
file" in
  the agent configuration form

A path typed into any of these names a filesystem no agent on the
instance uses.
The project workspace API also accepts a `cwd` write and stores it.

Separately, `/workspaces`, `/execution-workspaces/*`, and
`/projects/:projectId/workspaces/:workspaceId` render for anyone who
types or
bookmarks the URL, even with `enableIsolatedWorkspaces` off. Only the
sidebar
entry reads that flag.

**Proposed behavior**

With `enableManagedSandboxOnly` on, none of those surfaces render. A
project
whose codebase came from a managed checkout keeps its one-line
"Paperclip-managed folder." label and shows no path. The non-path
controls stay:
repo URL, branch, service URL, port, command output, ACP session mode,
ACP
non-interactive permissions, Codex fast mode, and the sandbox toggles.

The project-workspace create and patch routes, and the nested workspace
on
project create, answer `422` with
"This instance runs agents only in the platform-managed environment;
local
folders are not configurable." when the payload carries a non-null
`cwd`.
A `cwd: null` write still passes, so an instance that just turned the
feature on
can clear a stale path.

With `enableIsolatedWorkspaces` off, the three workspace route groups
redirect to
the dashboard.

**Reason and benefit**

A control that cannot do anything is worse than a missing control: the
user fills
it in, saves, and gets no error and no effect. The server rule turns
that silent
no-op into a clear refusal. The route gate stops a feature that an
instance has
turned off from staying reachable by URL, which is the same standard the
Cases,
Pipelines, and hidden-settings pages already meet.

**Breaking changes**

None for a default instance: both flags are off by default for
self-hosted and
managed instances, so nothing changes unless an operator turns them on.
Stored
`adapterConfig` values are never cleared, so turning the feature off
restores
every previous value.

## What Changed

- Add `ui/src/hooks/useManagedSandboxOnly.ts`, modelled on
`useAppsEnabled`, for
components that do not already read the experimental settings. It
exposes
`hideHostPaths`, which fails closed while the settings query is in
flight, so
a cold cache never flashes a host path before the policy resolves.
Components
that keep their own settings read compute the same gate from
`isFetched`.
- Add `managedSandboxOnly` to `AdapterConfigFieldsProps` and populate it
where
`AgentConfigForm` builds the adapter field props. Resolve the effective
instructions-file gate once as `hideInstructionsFile || hideHostPaths`,
so
  every adapter hides that path field with no per-adapter edit.
- Hide under the flag: the project "Local folder" block and its
absolute-path
  edit panel (a managed checkout keeps its label, without the path); the
new-project "Local folder" field; the project-workspace "Local path"
field and
fact row; the execution-workspace "Paths" and "Lifecycle commands"
groups; the
working directory on the workspace summary card, the task workspace
card, the
task properties "Folder" row, and the runtime service rows; the plugin
"Local
folders" section; "Working directory (deprecated)" and "Command" in the
agent
form; and the per-adapter "Execution engine", "ACP server command", and
"ACP state directory" for `claude_local`, `codex_local`, and
`gemini_local`.
- Drop two working-directory fallbacks that had no gate to read: the
close-workspace
dialog now falls back to "No additional details", and the reuse-existing
  workspace label and picker subtitle fall back to a neutral phrase.
- Refuse a non-null `cwd` with `422` on `POST /projects/:id/workspaces`,
`PATCH /projects/:id/workspaces/:workspaceId`, and the nested workspace
on
  `POST /companies/:companyId/projects`, following the
  `assertNoAgentHostWorkspaceCommandMutation` precedent on those routes.
- Add `IsolatedWorkspacesRouteGate` and wrap the `/workspaces`,
`/execution-workspaces/*`, and
`/projects/:projectId/workspaces/:workspaceId`
  routes with it.
- Leave the SSH "Remote workspace path" and the workspace file browser
alone,
  with a comment explaining why.

## Verification

Automated:

- `pnpm --filter @paperclipai/ui exec vitest run` — 479 of 480 files
pass
(4455 of 4456 tests). The one failure is `OnboardingWizard.test.tsx >
renders
instead of throwing when the browser denies storage access`, which also
fails
  on `origin/master` and is unrelated to this change.
- `pnpm --filter @paperclipai/server exec vitest run project workspace
instance-settings`
— 45 of 50 files pass. Four files fail on macOS for reasons unrelated to
this
  change: `workspace-instance-cleanup`, `workspace-runtime`,
  `execution-workspace-runtime-control-conflict`, and
  `workspace-runtime-exposure` compare `/var/...` against the resolved
`/private/var/...` or bind real ports. The same files fail on a clean
`master`
  checkout on the same machine.
- `pnpm --filter @paperclipai/ui typecheck`
- `tsc --noEmit` in `server/` (after
`pnpm --filter @paperclipai/plugin-sdk ensure-build-deps`). The package
`typecheck` script also builds the Rust runner, which needs `cargo`; it
is not
  installed on the machine that ran this.

New and extended tests:

- `ui/src/adapters/managed-sandbox-only-config-fields.test.tsx` — the
three
adapters drop the execution engine, the ACP paths, the instructions-file
path,
and every "Choose" button when the flag is on, and keep the non-path
controls.
- `ui/src/components/AgentConfigForm.render.test.tsx` — flag-on and
flag-off
renders for the working directory, the command, the engine, the ACP
paths, and
  the resolved adapter field props.
- `ui/src/components/ProjectProperties.managed-sandbox.test.tsx`,
  `ui/src/components/NewProjectDialog.managed-sandbox.test.tsx`,
  `ui/src/pages/ProjectWorkspaceDetail.test.tsx`,
  `ui/src/components/ProjectWorkspaceSummaryCard.test.tsx`,
  `ui/src/components/WorkspaceRuntimeControls.test.tsx`.
- `ui/src/components/IsolatedWorkspacesRouteGate.test.tsx` — redirect
when off,
  render when on, and render nothing while the flag query is in flight.
- "Still loading" cases for the project properties, the new-project
dialog, the
workspace summary card, the runtime service rows, and the agent
configuration
form, each asserting that no host path renders before the policy
resolves.
-
`server/src/__tests__/project-workspace-managed-sandbox-routes.test.ts`
— the
  `422` on all three write paths, the `cwd: null` pass-through, and the
  flag-off pass-through.

Manual check to reproduce: turn on Managed Environment Only in instance
experimental settings, then open a project, the new-project dialog, an
agent's
configuration, and a workspace page. No path, folder icon, or "Choose"
button
appears. Turn the setting off and each control returns with its stored
value.

No documentation change was needed. The operator-facing text for both
settings
lives in the feature catalog entry, which already states the contract
this pull
request now enforces across the UI.

## Risks

- Low. Both flags default to off, so a default instance is unchanged.
- The hidden fields are presentation only. No stored `adapterConfig`
value is
cleared, because an import carries adapter configuration written on
another
instance and clearing it would break that flow. Turning the setting off
shows
  every previous value again.
- The `422` is the one behavior change for an API caller, and only while
the
setting is on. `cwd: null` still passes so a stale path can be cleared.
- The route gate renders nothing until the flag query settles, so an
instance
with isolated workspaces on never flashes a redirect. An instance with
the
  feature off now redirects a bookmarked workspace URL to the dashboard.
- Every host-path guard fails closed while the settings query is in
flight, so a
default instance shows those controls a moment later than before on a
cold
load. That is the safe direction: the alternative flashes a path a
managed
  instance must never show.
- Two path surfaces stay on purpose, each with a comment: the SSH
"Remote
workspace path" is a path on the user's own remote host, and the
workspace file
browser shows workspace-relative paths. The instance Adapters page also
keeps
its "Local path" install option, since that page is an instance-admin
surface
the hosting operator can already hide through the hidden-settings
mechanism.

## Model Used

Claude (Anthropic), Claude Opus, 1M context window, extended thinking,
agentic
tool use through 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. `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
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [x] All Paperclip CI gates are green
- [x] 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-27 11:59:14 -07:00

2633 lines
108 KiB
TypeScript

import {
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { ArrowLeft, Check, Link2, Lock, Play, Plus, RefreshCw, RotateCcw, Terminal, Trash2, X } from "lucide-react";
import { FitAddon } from "@xterm/addon-fit";
import { Terminal as XTermTerminal } from "@xterm/xterm";
import "@xterm/xterm/css/xterm.css";
import {
type EnvBinding,
type Environment,
type EnvironmentDeleteBlastRadius,
type EnvironmentProviderCapability,
type EnvironmentProbeResult,
type EnvironmentCustomImageSetupSession,
type JsonSchema,
} from "@paperclipai/shared";
import {
environmentsApi,
type EnvironmentCustomImageActiveTemplateDrift,
type EnvironmentCustomImageConnectionPayload,
type EnvironmentCustomImageRelinkConflict,
type EnvironmentCustomImageSetupSessionResult,
type EnvironmentUpdateResult,
} from "@/api/environments";
import { agentsApi } from "@/api/agents";
import { ApiError } from "@/api/client";
import { instanceSettingsApi } from "@/api/instanceSettings";
import { secretsApi } from "@/api/secrets";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
import { Button } from "@/components/ui/button";
import {
EnvironmentVariablesEditor,
type EnvironmentVariablesEditorHandle,
} from "@/components/environment-variables-editor";
import { JsonSchemaForm, getDefaultValues, validateJsonSchemaForm } from "@/components/JsonSchemaForm";
import {
SecretRefHintsContext,
type SecretRefHint,
type SecretRefHintsContextValue,
} from "@/components/SecretBindingPicker";
import { useBreadcrumbs } from "@/context/BreadcrumbContext";
import { useCompany } from "@/context/CompanyContext";
import { useToast } from "@/context/ToastContext";
import { environmentDisplayLabel, isPlatformManagedEnvironment } from "@/lib/managed-sandbox-environment";
import { queryKeys } from "@/lib/queryKeys";
import { Link, useNavigate, useParams } from "@/lib/router";
import { buildSameOriginWebSocketUrl } from "@/lib/websocket-url";
import {
Field,
ToggleField,
} from "../components/agent-config-primitives";
type EnvironmentFormState = {
name: string;
description: string;
driver: "local" | "ssh" | "sandbox";
sshHost: string;
sshPort: string;
sshUsername: string;
sshRemoteWorkspacePath: string;
sshPrivateKey: string;
sshPrivateKeySecretId: string;
sshKnownHosts: string;
sshStrictHostKeyChecking: boolean;
sandboxProvider: string;
sandboxConfig: Record<string, unknown>;
envVars: Record<string, EnvBinding>;
};
type CompanyEnvironmentsMode = "list" | "create" | "edit";
type CompanyEnvironmentsProps = {
mode?: CompanyEnvironmentsMode;
};
const ENVIRONMENTS_PATH = "/company/settings/instance/environments";
function environmentEditPath(environmentId: string) {
return `${ENVIRONMENTS_PATH}/${encodeURIComponent(environmentId)}/edit`;
}
// Keep in sync with environmentDeleteBlockMessage in server/src/routes/environments.ts —
// the server enforces these gates with a 409; this copy lets the modal explain
// the block before the user hits it.
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.";
}
if (impact.pendingCleanupLeaseCount > 0) {
return "Cannot delete this environment while a sandbox cleanup is pending. Wait for the cleanup sweep to destroy the orphan sandbox, then retry.";
}
if (impact.reusableSandboxLeaseCount > 0) {
return "Cannot delete this environment while it has a reusable sandbox lease. Remove the associated execution workspace or issue so Paperclip can destroy the sandbox, then retry.";
}
return null;
}
function buildEnvironmentPayload(form: EnvironmentFormState) {
return {
name: form.name.trim(),
description: form.description.trim() || null,
driver: form.driver,
envVars: form.envVars,
config:
form.driver === "ssh"
? {
host: form.sshHost.trim(),
port: Number.parseInt(form.sshPort || "22", 10) || 22,
username: form.sshUsername.trim(),
remoteWorkspacePath: form.sshRemoteWorkspacePath.trim(),
privateKey: form.sshPrivateKey.trim() || null,
privateKeySecretRef:
form.sshPrivateKey.trim().length > 0 || !form.sshPrivateKeySecretId
? null
: { type: "secret_ref" as const, secretId: form.sshPrivateKeySecretId, version: "latest" as const },
knownHosts: form.sshKnownHosts.trim() || null,
strictHostKeyChecking: form.sshStrictHostKeyChecking,
}
: form.driver === "sandbox"
? {
provider: form.sandboxProvider.trim(),
...form.sandboxConfig,
}
: {},
} as const;
}
function createEmptyEnvironmentForm(): EnvironmentFormState {
return {
name: "",
description: "",
driver: "ssh",
sshHost: "",
sshPort: "22",
sshUsername: "",
sshRemoteWorkspacePath: "",
sshPrivateKey: "",
sshPrivateKeySecretId: "",
sshKnownHosts: "",
sshStrictHostKeyChecking: true,
sandboxProvider: "",
sandboxConfig: {},
envVars: {},
};
}
function isLocalEnvironment(environment: Environment | null | undefined) {
return environment?.driver === "local";
}
function normalizeNonLocalEnvironmentId(
environmentId: string | null | undefined,
environments: readonly Environment[],
): string {
if (!environmentId) return "";
const environment = environments.find((candidate) => candidate.id === environmentId) ?? null;
return isLocalEnvironment(environment) ? "" : environmentId;
}
function readSshConfig(environment: Environment) {
const config = environment.config ?? {};
return {
host: typeof config.host === "string" ? config.host : "",
port:
typeof config.port === "number"
? String(config.port)
: typeof config.port === "string"
? config.port
: "22",
username: typeof config.username === "string" ? config.username : "",
remoteWorkspacePath:
typeof config.remoteWorkspacePath === "string" ? config.remoteWorkspacePath : "",
privateKey: "",
privateKeySecretId:
config.privateKeySecretRef &&
typeof config.privateKeySecretRef === "object" &&
!Array.isArray(config.privateKeySecretRef) &&
typeof (config.privateKeySecretRef as { secretId?: unknown }).secretId === "string"
? String((config.privateKeySecretRef as { secretId: string }).secretId)
: "",
knownHosts: typeof config.knownHosts === "string" ? config.knownHosts : "",
strictHostKeyChecking:
typeof config.strictHostKeyChecking === "boolean"
? config.strictHostKeyChecking
: true,
};
}
function readSandboxConfig(environment: Environment) {
const config = environment.config ?? {};
const { provider: rawProvider, ...providerConfig } = config;
return {
provider: typeof rawProvider === "string" && rawProvider.trim().length > 0
? rawProvider
: "fake",
config: providerConfig,
};
}
function createEnvironmentFormFromEnvironment(environment: Environment): EnvironmentFormState {
if (environment.driver === "ssh") {
const ssh = readSshConfig(environment);
return {
...createEmptyEnvironmentForm(),
name: environment.name,
description: environment.description ?? "",
driver: "ssh",
sshHost: ssh.host,
sshPort: ssh.port,
sshUsername: ssh.username,
sshRemoteWorkspacePath: ssh.remoteWorkspacePath,
sshPrivateKey: ssh.privateKey,
sshPrivateKeySecretId: ssh.privateKeySecretId,
sshKnownHosts: ssh.knownHosts,
sshStrictHostKeyChecking: ssh.strictHostKeyChecking,
envVars: environment.envVars ?? {},
};
}
if (environment.driver === "sandbox") {
const sandbox = readSandboxConfig(environment);
return {
...createEmptyEnvironmentForm(),
name: environment.name,
description: environment.description ?? "",
driver: "sandbox",
sandboxProvider: sandbox.provider,
sandboxConfig: sandbox.config,
envVars: environment.envVars ?? {},
};
}
return {
...createEmptyEnvironmentForm(),
name: environment.name,
description: environment.description ?? "",
driver: "local",
envVars: environment.envVars ?? {},
};
}
const DISCARD_ENVIRONMENT_CHANGES_MESSAGE = "Discard unsaved environment changes?";
function stableJsonStringify(value: unknown): string {
if (Array.isArray(value)) {
return `[${value.map((item) => stableJsonStringify(item)).join(",")}]`;
}
if (value && typeof value === "object") {
const entries = Object.entries(value as Record<string, unknown>)
.filter(([, entryValue]) => entryValue !== undefined)
.sort(([left], [right]) => left.localeCompare(right));
return `{${entries
.map(([key, entryValue]) => `${JSON.stringify(key)}:${stableJsonStringify(entryValue)}`)
.join(",")}}`;
}
return JSON.stringify(value) ?? "undefined";
}
/** Payload-level fingerprint so cosmetic form state (whitespace, key order) is not "unsaved". */
function environmentFormKey(form: EnvironmentFormState): string {
return stableJsonStringify(buildEnvironmentPayload(form));
}
function normalizeJsonSchema(schema: unknown): JsonSchema | null {
return schema && typeof schema === "object" && !Array.isArray(schema)
? schema as JsonSchema
: null;
}
function summarizeSandboxConfig(config: Record<string, unknown>): string | null {
for (const key of ["template", "image", "region", "workspacePath"]) {
const value = config[key];
if (typeof value === "string" && value.trim().length > 0) {
return value;
}
}
return null;
}
const ACTIVE_CUSTOM_IMAGE_SETUP_STATUSES = new Set<EnvironmentCustomImageSetupSession["status"]>([
"starting",
"waiting_for_user",
"capturing",
]);
function isActiveCustomImageSetupSession(session: EnvironmentCustomImageSetupSession | null | undefined) {
return Boolean(session && ACTIVE_CUSTOM_IMAGE_SETUP_STATUSES.has(session.status));
}
function readEnvironmentSandboxProvider(environment: Environment): string | null {
return environment.driver === "sandbox" && typeof environment.config.provider === "string"
? environment.config.provider
: null;
}
function formatDateTime(value: string | Date | null | undefined): string | null {
if (!value) return null;
const date = value instanceof Date ? value : new Date(value);
return Number.isNaN(date.getTime()) ? null : date.toLocaleString();
}
function formatShortId(value: string): string {
const normalized = value.trim();
if (normalized.length <= 12) return normalized;
return `${normalized.slice(0, 12)}…`;
}
function formatBootSourceDriftValue(value: unknown): string {
if (typeof value === "string") return value;
if (value === null || value === undefined) return "none";
return JSON.stringify(value);
}
/**
* Builds the drift summary for a `boot_source_drift` overview. It names each
* changed boot-source field with its `from` and `to` values (example: "snapshot
* `a` -> `b`"). It uses only value-bearing paths; an excluded path carries the
* name only, so the summary omits it. Returns `null` when no value-bearing path
* is present, so the banner keeps the generic text.
*/
function formatBootSourceDriftSummary(
drift: EnvironmentCustomImageActiveTemplateDrift | null | undefined,
): string | null {
if (!drift || drift.classification !== "boot_source_drift") return null;
const parts = drift.driftedPaths
.filter((entry) => "from" in entry || "to" in entry)
.map(
(entry) =>
`${entry.path} \`${formatBootSourceDriftValue(entry.from)}\` -> \`${formatBootSourceDriftValue(entry.to)}\``,
);
if (parts.length === 0) return null;
return `Base image changed: ${parts.join("; ")}`;
}
function readConnectionCommand(payload: EnvironmentCustomImageConnectionPayload | null | undefined): string | null {
return typeof payload?.command === "string" && payload.command.trim().length > 0
? payload.command
: null;
}
function setupConnectionFallbackMessage(input: {
payload: EnvironmentCustomImageConnectionPayload | null;
refreshError: unknown;
isLoading: boolean;
}): string | null {
if (input.refreshError) {
return "Setup connection details could not be refreshed. You can still finish or cancel this setup.";
}
if (input.isLoading) return null;
if (!input.payload) {
return "Connection details are not available yet. You can still finish or cancel this setup.";
}
if (input.payload.type !== "ssh") {
return "Browser terminal is not available for this provider connection. Use the provider setup instructions, then finish or cancel here.";
}
if (!readConnectionCommand(input.payload)) {
return "Connection details are not available yet. You can still finish or cancel this setup.";
}
return null;
}
const CUSTOM_IMAGE_TERMINAL_COLS = 100;
const CUSTOM_IMAGE_TERMINAL_ROWS = 28;
const CUSTOM_IMAGE_TERMINAL_SCROLLBACK_ROWS = 5_000;
const CUSTOM_IMAGE_TERMINAL_FONT_FAMILY = [
"MesloLGS NF",
"MesloLGS Nerd Font Mono",
"CaskaydiaCove Nerd Font Mono",
"CaskaydiaMono Nerd Font",
"JetBrainsMono Nerd Font",
"FiraCode Nerd Font Mono",
"Symbols Nerd Font Mono",
"Menlo",
"Monaco",
"Consolas",
"Liberation Mono",
"monospace",
].join(", ");
type CustomImageTerminalConnectionState =
| "idle"
| "connecting"
| "connected"
| "closed"
| "error";
function appendTerminalQuery(path: string, params: Record<string, string | number>) {
const separator = path.includes("?") ? "&" : "?";
return `${path}${separator}${new URLSearchParams(
Object.fromEntries(Object.entries(params).map(([key, value]) => [key, String(value)])),
).toString()}`;
}
function parseTerminalFrame(raw: string): Record<string, unknown> | null {
try {
const parsed = JSON.parse(raw) as unknown;
return parsed && typeof parsed === "object" && !Array.isArray(parsed)
? parsed as Record<string, unknown>
: null;
} catch {
return null;
}
}
function customImageTerminalStatusCopy(state: CustomImageTerminalConnectionState) {
switch (state) {
case "connecting":
return "Connecting";
case "connected":
return "Connected";
case "closed":
return "Closed";
case "error":
return "Connection failed";
case "idle":
default:
return "Ready to connect";
}
}
function customImageTerminalCloseReasonCopy(reason: unknown) {
if (
reason !== "expired"
&& reason !== "ssh_closed"
&& reason !== "server_shutdown"
&& reason !== "setup_finished"
&& reason !== "setup_cancelled"
) {
return typeof reason === "string" && reason.trim() ? "Terminal closed." : null;
}
switch (reason) {
case "expired":
return "Setup session expired.";
case "ssh_closed":
return "SSH session closed.";
case "server_shutdown":
return "Terminal server shut down.";
case "setup_finished":
return "Setup session finished.";
case "setup_cancelled":
return "Setup session cancelled.";
default:
return null;
}
}
function EnvironmentCustomImageBrowserTerminal({
autoConnect = false,
sessionId,
}: {
autoConnect?: boolean;
sessionId: string;
}) {
const [connectionState, setConnectionState] = useState<CustomImageTerminalConnectionState>("idle");
const [errorMessage, setErrorMessage] = useState<string | null>(null);
const terminalElementRef = useRef<HTMLDivElement | null>(null);
const xtermRef = useRef<XTermTerminal | null>(null);
const fitAddonRef = useRef<FitAddon | null>(null);
const terminalInputDisposableRef = useRef<{ dispose: () => void } | null>(null);
const resizeObserverRef = useRef<ResizeObserver | null>(null);
const fitFrameRef = useRef<number | null>(null);
const socketRef = useRef<WebSocket | null>(null);
const autoConnectAttemptedSessionRef = useRef<string | null>(null);
const lastSentResizeRef = useRef<{ cols: number; rows: number } | null>(null);
const closeSocket = useCallback((reason = "operator_closed") => {
const socket = socketRef.current;
socketRef.current = null;
if (socket && socket.readyState !== WebSocket.CLOSED && socket.readyState !== WebSocket.CLOSING) {
socket.close(1000, reason);
}
}, []);
const getTerminalDimensions = useCallback(() => {
const terminal = xtermRef.current;
return {
cols: terminal?.cols || CUSTOM_IMAGE_TERMINAL_COLS,
rows: terminal?.rows || CUSTOM_IMAGE_TERMINAL_ROWS,
};
}, []);
const sendTerminalResize = useCallback((force = false) => {
const socket = socketRef.current;
if (!socket || socket.readyState !== WebSocket.OPEN) return;
const dimensions = getTerminalDimensions();
const previous = lastSentResizeRef.current;
if (!force && previous?.cols === dimensions.cols && previous.rows === dimensions.rows) return;
lastSentResizeRef.current = dimensions;
socket.send(JSON.stringify({
type: "resize",
cols: dimensions.cols,
rows: dimensions.rows,
}));
}, [getTerminalDimensions]);
const fitTerminal = useCallback(() => {
const fitAddon = fitAddonRef.current;
if (!fitAddon || !xtermRef.current) return;
try {
fitAddon.fit();
sendTerminalResize();
} catch {
// The fit addon can throw during hidden/dialog layout transitions. The
// next ResizeObserver tick or reconnect will retry with stable dimensions.
}
}, [sendTerminalResize]);
const requestFitTerminal = useCallback(() => {
if (fitFrameRef.current !== null) {
window.cancelAnimationFrame(fitFrameRef.current);
}
fitFrameRef.current = window.requestAnimationFrame(() => {
fitFrameRef.current = null;
fitTerminal();
});
}, [fitTerminal]);
const sendTerminalInput = useCallback((data: string) => {
const socket = socketRef.current;
if (!socket || socket.readyState !== WebSocket.OPEN) return;
socket.send(JSON.stringify({ type: "input", data }));
}, []);
const resetTerminalScreen = useCallback(() => {
const terminal = xtermRef.current;
if (!terminal) return;
terminal.reset();
terminal.clear();
}, []);
useEffect(() => {
const element = terminalElementRef.current;
if (!element || xtermRef.current) return undefined;
const terminal = new XTermTerminal({
allowTransparency: true,
cols: CUSTOM_IMAGE_TERMINAL_COLS,
rows: CUSTOM_IMAGE_TERMINAL_ROWS,
convertEol: false,
cursorBlink: true,
cursorInactiveStyle: "bar",
cursorStyle: "bar",
cursorWidth: 2,
customGlyphs: true,
fontFamily: CUSTOM_IMAGE_TERMINAL_FONT_FAMILY,
fontSize: 12,
letterSpacing: 0,
lineHeight: 1.35,
scrollback: CUSTOM_IMAGE_TERMINAL_SCROLLBACK_ROWS,
theme: {
// token-extraction: allowlisted — xterm.js terminal theme config; functional third-party option object, not a rendered CSS value.
background: "#0a0a0a",
foreground: "#f5f5f5",
cursor: "#22d3ee",
cursorAccent: "#020617",
selectionBackground: "#2563eb55",
},
});
const fitAddon = new FitAddon();
terminal.loadAddon(fitAddon);
terminal.open(element);
xtermRef.current = terminal;
fitAddonRef.current = fitAddon;
terminalInputDisposableRef.current = terminal.onData(sendTerminalInput);
if (typeof ResizeObserver !== "undefined") {
const resizeObserver = new ResizeObserver(() => requestFitTerminal());
resizeObserver.observe(element);
resizeObserverRef.current = resizeObserver;
}
terminal.focus();
requestFitTerminal();
const fitTimeouts = [50, 250].map((delay) => window.setTimeout(fitTerminal, delay));
const fontsReady = "fonts" in document
? (document as Document & { fonts?: { ready?: Promise<unknown> } }).fonts?.ready
: null;
if (fontsReady) {
void fontsReady.then(() => fitTerminal());
}
return () => {
if (fitFrameRef.current !== null) {
window.cancelAnimationFrame(fitFrameRef.current);
fitFrameRef.current = null;
}
for (const timeoutId of fitTimeouts) {
window.clearTimeout(timeoutId);
}
resizeObserverRef.current?.disconnect();
resizeObserverRef.current = null;
terminalInputDisposableRef.current?.dispose();
terminalInputDisposableRef.current = null;
fitAddonRef.current = null;
xtermRef.current = null;
terminal.dispose();
};
}, [fitTerminal, requestFitTerminal, sendTerminalInput]);
useEffect(() => () => closeSocket("component_unmounted"), [closeSocket]);
useEffect(() => {
closeSocket("session_changed");
autoConnectAttemptedSessionRef.current = null;
lastSentResizeRef.current = null;
setConnectionState("idle");
setErrorMessage(null);
resetTerminalScreen();
}, [closeSocket, resetTerminalScreen, sessionId]);
useEffect(() => {
if (connectionState === "connected") {
xtermRef.current?.focus();
}
}, [connectionState]);
const connectTerminal = useCallback(async () => {
if (typeof WebSocket === "undefined") {
setConnectionState("error");
setErrorMessage("Browser terminal is unavailable in this browser.");
return;
}
closeSocket("reconnect");
setConnectionState("connecting");
lastSentResizeRef.current = null;
setErrorMessage(null);
resetTerminalScreen();
xtermRef.current?.focus();
try {
fitTerminal();
const dimensions = getTerminalDimensions();
const terminalToken = await environmentsApi.createCustomImageTerminalSessionToken(sessionId, {});
const websocketPath = appendTerminalQuery(terminalToken.websocketPath, {
cols: dimensions.cols,
rows: dimensions.rows,
});
const socket = new WebSocket(buildSameOriginWebSocketUrl(websocketPath));
socketRef.current = socket;
socket.onopen = () => {
if (socketRef.current !== socket) return;
xtermRef.current?.focus();
socket.send(JSON.stringify({ type: "auth", token: terminalToken.token }));
sendTerminalResize(true);
};
socket.onmessage = (message) => {
if (socketRef.current !== socket) return;
const raw = typeof message.data === "string" ? message.data : "";
const frame = raw ? parseTerminalFrame(raw) : null;
if (!frame) return;
if (frame.type === "ready") {
setConnectionState("connected");
xtermRef.current?.focus();
return;
}
if (frame.type === "output" && typeof frame.data === "string") {
xtermRef.current?.write(frame.data as string);
return;
}
if (frame.type === "error") {
setConnectionState("error");
setErrorMessage(typeof frame.message === "string" ? frame.message : "Terminal connection failed.");
return;
}
if (frame.type === "closed") {
setConnectionState("closed");
setErrorMessage(customImageTerminalCloseReasonCopy(frame.reason));
}
};
socket.onclose = () => {
if (socketRef.current !== socket) return;
socketRef.current = null;
setConnectionState((current) => current === "connected" || current === "connecting" ? "closed" : current);
};
socket.onerror = () => {
if (socketRef.current !== socket) return;
setConnectionState("error");
setErrorMessage("Terminal websocket connection failed.");
};
} catch (error) {
setConnectionState("error");
setErrorMessage(error instanceof Error ? error.message : "Terminal session could not be opened.");
}
}, [closeSocket, fitTerminal, getTerminalDimensions, resetTerminalScreen, sendTerminalResize, sessionId]);
useEffect(() => {
if (!autoConnect || connectionState !== "idle") return;
if (autoConnectAttemptedSessionRef.current === sessionId) return;
const timeoutId = window.setTimeout(() => {
autoConnectAttemptedSessionRef.current = sessionId;
void connectTerminal();
}, 0);
return () => window.clearTimeout(timeoutId);
}, [autoConnect, connectTerminal, connectionState, sessionId]);
const disconnectTerminal = useCallback(() => {
closeSocket("operator_closed");
setConnectionState("closed");
}, [closeSocket]);
const terminalInteractive = connectionState === "connected";
return (
<div className="mt-3 rounded-md border border-border/70 bg-background" data-testid={`custom-image-terminal-${sessionId}`}>
<div className="flex flex-wrap items-center justify-between gap-2 border-b border-border/60 px-3 py-2">
<div className="flex min-w-0 items-center gap-2 text-xs">
<Terminal className="h-3.5 w-3.5 text-muted-foreground" />
<span className="font-medium">Browser terminal</span>
<span className="text-muted-foreground">{customImageTerminalStatusCopy(connectionState)}</span>
</div>
<div className="flex items-center gap-2">
{terminalInteractive ? (
<Button size="sm" variant="ghost" onClick={disconnectTerminal}>
Disconnect
</Button>
) : (
<Button
size="sm"
variant="outline"
onClick={() => void connectTerminal()}
disabled={connectionState === "connecting"}
>
<Terminal className="mr-1.5 h-3.5 w-3.5" />
{connectionState === "closed" || connectionState === "error" ? "Reconnect" : "Open terminal"}
</Button>
)}
</div>
</div>
<div className="bg-neutral-950 p-2 focus-within:ring-2 focus-within:ring-ring">
<div
ref={terminalElementRef}
data-testid={`custom-image-terminal-screen-${sessionId}`}
aria-label="Custom image browser terminal"
role="application"
tabIndex={0}
onFocus={() => xtermRef.current?.focus()}
onClick={() => xtermRef.current?.focus()}
className="h-(--sz-18rem) w-full overflow-hidden bg-neutral-950 outline-none sm:h-(--sz-22rem) [&_.xterm-cursor-bar]:!border-l-2 [&_.xterm-cursor-bar]:!border-l-cyan-300 [&_.xterm-cursor-layer_.xterm-cursor]:!bg-cyan-300 [&_.xterm-helper-textarea]:!opacity-0 [&_.xterm-screen]:focus:outline-none [&_.xterm-viewport]:!overflow-y-auto [&_.xterm]:h-full"
/>
</div>
{errorMessage ? (
<div className="border-t border-border/60 px-3 py-2 text-xs text-destructive">
{errorMessage}
</div>
) : null}
</div>
);
}
function capabilityState(capability: EnvironmentProviderCapability | null | undefined) {
if (!capability || capability.status !== "supported" || !capability.supportsInteractiveSetup) {
return {
kind: "unsupported" as const,
label: "Unsupported provider",
reason: "This provider does not advertise interactive template setup.",
};
}
if (!capability.supportsTemplateCapture) {
return {
kind: "capture_unavailable" as const,
label: "Setup capture unavailable",
reason: "This provider advertises setup, but image capture is unavailable.",
};
}
return {
kind: "supported" as const,
label: "Template setup",
reason: null,
};
}
function sessionStatusCopy(status: EnvironmentCustomImageSetupSession["status"]) {
switch (status) {
case "starting":
return "Setup starting";
case "waiting_for_user":
return "Setup running";
case "capturing":
return "Capturing template";
case "promoted":
return "Template captured";
case "cancelled":
return "Setup cancelled";
case "timed_out":
return "Setup expired";
case "failed":
return "Setup failed";
default:
return "Setup status";
}
}
// The operator declined the drift confirmation prompt. It is not a failure, so
// the relink mutation stays quiet instead of showing an error toast.
class RelinkConfirmationDeclined extends Error {
constructor() {
super("relink confirmation declined");
this.name = "RelinkConfirmationDeclined";
}
}
function formatRelinkDriftValue(value: unknown): string {
if (value === null || value === undefined) return "(none)";
if (typeof value === "string") return value;
return JSON.stringify(value);
}
// Turns the sanitized 409 drift body into the operator warning. Value-bearing
// drift shows the changed field; an unclassified result warns that the snapshot
// will override the current base image.
function relinkDriftWarning(conflict: EnvironmentCustomImageRelinkConflict): string {
if (conflict.classification === "boot_source_drift") {
const valued = conflict.driftedPaths.find(
(entry) => entry.from !== undefined || entry.to !== undefined,
);
if (valued) {
return `The base image changed: ${valued.path} ${formatRelinkDriftValue(valued.from)} -> ${formatRelinkDriftValue(valued.to)}.`;
}
return "The base image changed since this image was captured.";
}
return "The server cannot verify the boot source; the snapshot will override the current base image.";
}
function EnvironmentImageTemplatePanel({
environment,
companyId,
providerCapability,
providerDisplayName,
}: {
environment: Environment;
companyId: string;
providerCapability: EnvironmentProviderCapability | null | undefined;
providerDisplayName: string;
}) {
const { pushToast } = useToast();
const queryClient = useQueryClient();
const state = capabilityState(providerCapability);
const overviewKey = queryKeys.environments.customImageTemplate(environment.id);
const overviewQuery = useQuery({
queryKey: overviewKey,
queryFn: () => environmentsApi.customImageTemplate(environment.id, companyId),
enabled: state.kind === "supported",
retry: false,
});
const activeSessionId = overviewQuery.data?.activeSession?.id ?? null;
const sessionQuery = useQuery({
queryKey: activeSessionId
? queryKeys.environments.customImageSetupSession(activeSessionId)
: ["environment-custom-image-setup-sessions", "none", environment.id],
queryFn: () => environmentsApi.customImageSetupSession(activeSessionId!),
enabled: Boolean(activeSessionId && isActiveCustomImageSetupSession(overviewQuery.data?.activeSession)),
retry: false,
});
function setSessionResult(result: EnvironmentCustomImageSetupSessionResult) {
queryClient.setQueryData(
queryKeys.environments.customImageSetupSession(result.session.id),
result,
);
}
function invalidateOverview() {
void queryClient.invalidateQueries({ queryKey: overviewKey });
}
const startSetupMutation = useMutation({
mutationFn: (input: { templateId?: string | null } = {}) =>
environmentsApi.startCustomImageSetupSession(
environment.id,
companyId,
{ templateId: input.templateId ?? null },
),
onSuccess: (result) => {
queryClient.setQueryData(overviewKey, (current: typeof overviewQuery.data) => ({
activeTemplate: current?.activeTemplate ?? null,
activeSession: result.session,
latestSession: result.session,
}));
setSessionResult(result);
pushToast({
title: "Setup session started",
body: "Connect details are available while the session is active.",
tone: "success",
});
},
onError: (error) => {
pushToast({
title: "Failed to start setup",
body: error instanceof Error ? error.message : "Setup session could not be started.",
tone: "error",
});
},
});
const finishSetupMutation = useMutation({
mutationFn: (sessionId: string) => environmentsApi.finishCustomImageSetupSession(sessionId, {}),
onSuccess: (result) => {
queryClient.setQueryData(overviewKey, {
activeTemplate: result.template,
activeSession: null,
latestSession: result.session,
});
setSessionResult({ session: result.session, connectionPayload: null });
invalidateOverview();
pushToast({
title: "Template captured",
body: "Future runs can use the promoted template.",
tone: "success",
});
},
onError: (error) => {
pushToast({
title: "Failed to capture template",
body: error instanceof Error ? error.message : "Template capture failed.",
tone: "error",
});
},
});
const cancelSetupMutation = useMutation({
mutationFn: (sessionId: string) =>
environmentsApi.cancelCustomImageSetupSession(sessionId, { reason: "operator cancelled" }),
onSuccess: (session) => {
queryClient.setQueryData(overviewKey, (current: typeof overviewQuery.data) => ({
activeTemplate: current?.activeTemplate ?? null,
activeSession: null,
latestSession: session,
}));
setSessionResult({ session, connectionPayload: null });
invalidateOverview();
pushToast({
title: "Setup cancelled",
body: "The active template was not changed.",
tone: "success",
});
},
onError: (error) => {
pushToast({
title: "Failed to cancel setup",
body: error instanceof Error ? error.message : "Setup session could not be cancelled.",
tone: "error",
});
},
});
const rollbackTemplateMutation = useMutation({
mutationFn: () => environmentsApi.rollbackCustomImageTemplate(environment.id, companyId),
onSuccess: (result) => {
queryClient.setQueryData(overviewKey, (current: typeof overviewQuery.data) => ({
activeTemplate: result.activeTemplate,
activeSession: null,
latestSession: current?.latestSession ?? null,
}));
invalidateOverview();
pushToast({
title: "Template rolled back",
body: "Future runs will use the previous template.",
tone: "success",
});
},
onError: (error) => {
pushToast({
title: "Failed to roll back template",
body: error instanceof Error ? error.message : "Rollback failed.",
tone: "error",
});
},
});
const relinkTemplateMutation = useMutation({
// The route is called without the flag first. A 409 carries the sanitized
// drift detail; the operator must confirm before the flagged retry.
mutationFn: async () => {
try {
return await environmentsApi.relinkCustomImageTemplate(environment.id, companyId);
} catch (error) {
if (error instanceof ApiError && error.status === 409) {
const conflict = (error.body as { details?: EnvironmentCustomImageRelinkConflict } | null)?.details;
const warning = conflict ? relinkDriftWarning(conflict) : error.message;
if (!window.confirm(`${warning}\n\nRelink this image anyway?`)) {
throw new RelinkConfirmationDeclined();
}
return await environmentsApi.relinkCustomImageTemplate(environment.id, companyId, {
confirmBootSourceDrift: true,
});
}
throw error;
}
},
onSuccess: (result) => {
queryClient.setQueryData(overviewKey, (current: typeof overviewQuery.data) => ({
activeTemplate: result.template,
activeTemplateMatchesConfig: true,
activeSession: current?.activeSession ?? null,
latestSession: current?.latestSession ?? null,
}));
invalidateOverview();
pushToast({
title: "Template relinked",
body: "Runs use the captured image again.",
tone: "success",
});
},
onError: (error) => {
if (error instanceof RelinkConfirmationDeclined) return;
pushToast({
title: "Failed to relink template",
body: error instanceof Error ? error.message : "Relink failed.",
tone: "error",
});
},
});
const disableTemplateMutation = useMutation({
mutationFn: () => environmentsApi.disableCustomImageTemplate(environment.id, companyId),
onSuccess: (template) => {
queryClient.setQueryData(overviewKey, (current: typeof overviewQuery.data) => ({
activeTemplate: null,
activeSession: null,
latestSession: current?.latestSession ?? null,
}));
invalidateOverview();
pushToast({
title: "Template disabled",
body: "Future runs will use the base provider configuration.",
tone: "success",
});
},
onError: (error) => {
pushToast({
title: "Failed to disable template",
body: error instanceof Error ? error.message : "Disable failed.",
tone: "error",
});
},
});
if (state.kind !== "supported") {
return (
<div className="mt-3 border-t border-border/60 pt-3 text-xs" data-testid={`custom-image-template-state-${environment.id}`}>
<div className="font-medium text-foreground">{state.label}</div>
<div className="mt-1 text-muted-foreground">{state.reason}</div>
</div>
);
}
if (overviewQuery.isLoading) {
return (
<div className="mt-3 border-t border-border/60 pt-3 text-xs text-muted-foreground">
Loading template setup...
</div>
);
}
if (overviewQuery.isError) {
return (
<div className="mt-3 border-t border-border/60 pt-3 text-xs text-destructive">
{overviewQuery.error instanceof Error ? overviewQuery.error.message : "Template setup could not be loaded."}
</div>
);
}
const overview = overviewQuery.data;
const activeTemplate = overview?.activeTemplate ?? null;
const refreshedSession = sessionQuery.data?.session ?? null;
const session = refreshedSession ?? overview?.activeSession ?? null;
const latestSession = !isActiveCustomImageSetupSession(session)
? session ?? overview?.latestSession ?? null
: overview?.latestSession ?? null;
const connectionPayload = session?.status === "waiting_for_user"
? sessionQuery.data?.connectionPayload ?? null
: null;
const connectionCommand = readConnectionCommand(connectionPayload);
const connectionFallbackMessage = session?.status === "waiting_for_user"
? setupConnectionFallbackMessage({
payload: connectionPayload,
refreshError: sessionQuery.isError ? sessionQuery.error : null,
isLoading: sessionQuery.isLoading,
})
: null;
const sessionExpiresAt = formatDateTime(connectionPayload?.expiresAt ?? session?.expiresAt ?? null);
const capturedAt = formatDateTime(activeTemplate?.capturedAt ?? activeTemplate?.createdAt ?? null);
const lastUsedAt = formatDateTime(activeTemplate?.lastUsedAt ?? null);
const isMutating =
startSetupMutation.isPending ||
finishSetupMutation.isPending ||
cancelSetupMutation.isPending ||
relinkTemplateMutation.isPending ||
rollbackTemplateMutation.isPending ||
disableTemplateMutation.isPending;
if (session && isActiveCustomImageSetupSession(session)) {
const isCapturing = session.status === "capturing";
return (
<div className="mt-3 border-t border-border/60 pt-3" data-testid={`custom-image-template-state-${environment.id}`}>
<div className="flex flex-wrap items-start justify-between gap-3">
<div className="min-w-0 space-y-1">
<div className="text-xs font-medium">{sessionStatusCopy(session.status)}</div>
<div className="text-xs text-muted-foreground">
{providerDisplayName}{sessionExpiresAt ? ` · expires ${sessionExpiresAt}` : ""}
</div>
</div>
<div className="flex flex-wrap items-center gap-2">
<Button
size="sm"
variant="outline"
onClick={() => finishSetupMutation.mutate(session.id)}
disabled={isMutating || session.status !== "waiting_for_user"}
>
<Check className="mr-1.5 h-3.5 w-3.5" />
Finished
</Button>
<Button
size="sm"
variant="ghost"
onClick={() => cancelSetupMutation.mutate(session.id)}
disabled={isMutating}
>
<X className="mr-1.5 h-3.5 w-3.5" />
Cancel
</Button>
</div>
</div>
{isCapturing ? (
<div className="mt-2 text-xs text-muted-foreground">
Capture is in progress. If this state remains after a refresh or interrupted request, cancel it to return to the active template controls.
</div>
) : null}
{session.status === "waiting_for_user" && connectionPayload?.type === "ssh" ? (
<EnvironmentCustomImageBrowserTerminal autoConnect sessionId={session.id} />
) : null}
{session.status === "waiting_for_user" && connectionCommand ? (
<details className="mt-2 rounded-md border border-border/60 bg-muted/20 px-3 py-2 text-xs text-muted-foreground">
<summary className="cursor-pointer select-none font-medium text-foreground">
SSH command fallback
</summary>
<code className="mt-2 block overflow-x-auto whitespace-nowrap text-(length:--text-micro) leading-5">
{connectionCommand}
</code>
</details>
) : null}
{session.status === "waiting_for_user" && connectionFallbackMessage ? (
<div className="mt-2 text-xs text-muted-foreground">
{connectionFallbackMessage}
</div>
) : null}
{session.failureReason ? (
<div className="mt-2 text-xs text-destructive">{session.failureReason}</div>
) : null}
</div>
);
}
if (activeTemplate) {
const templateRef = activeTemplate.templateRef?.trim() || null;
const templateOutOfSync = overview?.activeTemplateMatchesConfig === false;
const bootSourceDriftSummary = formatBootSourceDriftSummary(overview?.activeTemplateDrift);
return (
<div className="mt-3 border-t border-border/60 pt-3" data-testid={`custom-image-template-state-${environment.id}`}>
<div className="flex flex-wrap items-start justify-between gap-3">
<div className="min-w-0 space-y-1">
<div className="text-xs font-medium">Active template</div>
<div className="text-xs text-muted-foreground">
{providerDisplayName} · {activeTemplate.templateKind}
{" · "}
<span
className="break-all font-mono text-foreground"
title={templateRef
? `Provider ${activeTemplate.templateKind} ref ${templateRef} (Paperclip template ${activeTemplate.id})`
: activeTemplate.id}
>
{templateRef ?? `id ${formatShortId(activeTemplate.id)}`}
</span>
{capturedAt ? ` · captured ${capturedAt}` : ""}
{lastUsedAt ? ` · last used ${lastUsedAt}` : ""}
</div>
{templateOutOfSync ? (
<div
className="text-xs text-destructive"
data-testid={`custom-image-template-out-of-sync-${environment.id}`}
>
{bootSourceDriftSummary
? `Not in use — ${bootSourceDriftSummary}. Runs fall back to the base configuration until you relink this image or capture a new one.`
: "Not in use — the environment configuration changed since this image was captured. Runs fall back to the base configuration until you relink this image or capture a new one."}
</div>
) : null}
</div>
<div className="flex flex-wrap items-center gap-2">
<Button
size="sm"
variant="outline"
onClick={() => startSetupMutation.mutate({ templateId: activeTemplate.id })}
disabled={isMutating}
>
<RefreshCw className="mr-1.5 h-3.5 w-3.5" />
Refresh
</Button>
<Button
size="sm"
variant="outline"
onClick={() => relinkTemplateMutation.mutate()}
disabled={isMutating}
data-testid={`custom-image-template-relink-${environment.id}`}
>
<Link2 className="mr-1.5 h-3.5 w-3.5" />
Relink
</Button>
<Button
size="sm"
variant="ghost"
onClick={() => rollbackTemplateMutation.mutate()}
disabled={isMutating}
>
<RotateCcw className="mr-1.5 h-3.5 w-3.5" />
Rollback
</Button>
<Button
size="sm"
variant="ghost"
onClick={() => disableTemplateMutation.mutate()}
disabled={isMutating}
>
<Trash2 className="mr-1.5 h-3.5 w-3.5" />
Disable
</Button>
</div>
</div>
</div>
);
}
return (
<div className="mt-3 border-t border-border/60 pt-3" data-testid={`custom-image-template-state-${environment.id}`}>
<div className="flex flex-wrap items-start justify-between gap-3">
<div className="space-y-1">
<div className="text-xs font-medium">Not configured</div>
<div className="text-xs text-muted-foreground">
{latestSession
? sessionStatusCopy(latestSession.status)
: `Capture a custom ${providerDisplayName} image with your tools already logged in.`}
</div>
{latestSession?.failureReason ? (
<div className="text-xs text-destructive">{latestSession.failureReason}</div>
) : null}
</div>
<Button
size="sm"
variant="outline"
onClick={() => startSetupMutation.mutate({ templateId: null })}
disabled={isMutating}
>
<Play className="mr-1.5 h-3.5 w-3.5" />
Configure image
</Button>
</div>
</div>
);
}
export function CompanyEnvironments({ mode = "list" }: CompanyEnvironmentsProps) {
const { environmentId: routeEnvironmentId } = useParams<{ environmentId?: string }>();
const navigate = useNavigate();
const { selectedCompanyId } = useCompany();
const { setBreadcrumbs } = useBreadcrumbs();
const { pushToast } = useToast();
const queryClient = useQueryClient();
const isEnvironmentFormPage = mode === "create" || mode === "edit";
const editingEnvironmentId = mode === "edit" ? routeEnvironmentId ?? null : null;
const [environmentForm, setEnvironmentForm] = useState<EnvironmentFormState>(createEmptyEnvironmentForm);
const environmentVariablesEditorRef = useRef<EnvironmentVariablesEditorHandle | null>(null);
const initializedFormKeyRef = useRef<string | null>(null);
// Fingerprint of the form as initialized; null until the form page has loaded its data.
const [environmentFormBaselineKey, setEnvironmentFormBaselineKey] = useState<string | null>(null);
const [environmentVariablesDirty, setEnvironmentVariablesDirty] = useState(false);
const [probeResults, setProbeResults] = useState<Record<string, EnvironmentProbeResult | null>>({});
const [testingEnvironmentId, setTestingEnvironmentId] = useState<string | null>(null);
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
// "" means "inherit the instance default", mirroring the agent config form's
// environment override select.
const [reassignEnvironmentTargetId, setReassignEnvironmentTargetId] = useState("");
const environmentHasUnsavedChanges =
isEnvironmentFormPage &&
(environmentVariablesDirty ||
(environmentFormBaselineKey !== null && environmentFormKey(environmentForm) !== environmentFormBaselineKey));
useEffect(() => {
const crumbs = [
{ label: "Settings", href: "/company/settings" },
isEnvironmentFormPage
? { label: "Environments", href: ENVIRONMENTS_PATH }
: { label: "Environments" },
];
if (mode === "create") crumbs.push({ label: "Add environment" });
if (mode === "edit") crumbs.push({ label: "Edit environment" });
setBreadcrumbs(crumbs);
}, [isEnvironmentFormPage, mode, setBreadcrumbs]);
const { data: instanceSettings } = useQuery({
queryKey: queryKeys.instance.settings,
queryFn: () => instanceSettingsApi.get(),
retry: false,
});
const { data: experimentalSettings } = useQuery({
queryKey: queryKeys.instance.experimentalSettings,
queryFn: () => instanceSettingsApi.getExperimental(),
retry: false,
});
const environmentsEnabled = experimentalSettings?.enableEnvironments === true;
const managedSandboxOnly = experimentalSettings?.enableManagedSandboxOnly === true;
const { data: environments } = useQuery({
queryKey: selectedCompanyId ? queryKeys.environments.list(selectedCompanyId) : ["environments", "none"],
queryFn: () => environmentsApi.list(selectedCompanyId!),
enabled: Boolean(selectedCompanyId) && environmentsEnabled,
});
const savedEnvironments = environments ?? [];
// Delete preflight: the blast radius names what still references the
// environment, and the agent list identifies which of this company's agents
// need reassignment. Both only load while the delete dialog is open.
const deleteBlastRadiusQuery = useQuery({
queryKey: editingEnvironmentId
? ["environment-delete-blast-radius", editingEnvironmentId]
: ["environment-delete-blast-radius", "none"],
queryFn: () => environmentsApi.deleteBlastRadius(editingEnvironmentId!),
enabled: deleteDialogOpen && Boolean(editingEnvironmentId),
retry: false,
});
const companyAgentsQuery = useQuery({
queryKey: selectedCompanyId ? queryKeys.agents.list(selectedCompanyId) : ["agents", "none"],
queryFn: () => agentsApi.list(selectedCompanyId!),
enabled: deleteDialogOpen && Boolean(selectedCompanyId),
});
// Descriptors for the edited environment's secret refs. Environments are
// instance-scoped while secrets are company-scoped, so a ref may point at
// a secret this company's picker cannot list; these hints let the picker
// name it instead of calling it missing.
const environmentSecretRefsQuery = useQuery({
queryKey: editingEnvironmentId
? ["environment-secret-refs", editingEnvironmentId]
: ["environment-secret-refs", "none"],
queryFn: () => environmentsApi.secretRefs(editingEnvironmentId!),
enabled: Boolean(editingEnvironmentId) && environmentsEnabled,
retry: false,
});
const environmentSecretRefHints = useMemo<SecretRefHintsContextValue>(() => {
// A new environment has no persisted refs, so the empty map is
// authoritative. For an existing environment the map is only "ready"
// once the descriptor request resolved — the picker must not call a
// reference missing off a pending or failed lookup.
if (!editingEnvironmentId) return { status: "ready", hints: {} };
if (environmentSecretRefsQuery.isError) return { status: "error", hints: {} };
if (!environmentSecretRefsQuery.data) return { status: "loading", hints: {} };
const hints: Record<string, SecretRefHint> = {};
for (const ref of environmentSecretRefsQuery.data.refs) {
hints[ref.secretId] = {
name: ref.name,
status: ref.status,
companyId: ref.companyId,
companyName: ref.companyName,
};
}
return { status: "ready", hints };
}, [editingEnvironmentId, environmentSecretRefsQuery.data, environmentSecretRefsQuery.isError]);
const { data: environmentCapabilities } = useQuery({
queryKey: selectedCompanyId ? ["environment-capabilities", selectedCompanyId] : ["environment-capabilities", "none"],
queryFn: () => environmentsApi.capabilities(selectedCompanyId!),
enabled: Boolean(selectedCompanyId) && environmentsEnabled,
});
const { data: secrets } = useQuery({
queryKey: selectedCompanyId ? ["company-secrets", selectedCompanyId] : ["company-secrets", "none"],
queryFn: () => secretsApi.list(selectedCompanyId!),
enabled: Boolean(selectedCompanyId),
});
const createSecret = useMutation({
mutationFn: (input: { name: string; value: string }) => {
if (!selectedCompanyId) throw new Error("Select an organization to create secrets");
return secretsApi.create(selectedCompanyId, input);
},
onSuccess: async () => {
if (!selectedCompanyId) return;
await queryClient.invalidateQueries({ queryKey: queryKeys.secrets.list(selectedCompanyId) });
},
});
// Managed (platform-provisioned) environments accept exactly one tenant
// edit: the env var map. The server's write floor rejects everything
// else, so this mutation sends an envVars-only PATCH — the one body
// shape the floor admits.
const managedEnvironmentEnvVarsMutation = useMutation({
mutationFn: async (envVars: EnvironmentFormState["envVars"]) => {
if (!editingEnvironmentId) throw new Error("No environment selected");
return await environmentsApi.update(editingEnvironmentId, { envVars }, selectedCompanyId);
},
onSuccess: async (environment) => {
if (selectedCompanyId) {
await queryClient.invalidateQueries({
queryKey: queryKeys.environments.list(selectedCompanyId),
});
}
initializedFormKeyRef.current = null;
setEnvironmentForm(createEmptyEnvironmentForm());
setEnvironmentFormBaselineKey(null);
setEnvironmentVariablesDirty(false);
navigate(ENVIRONMENTS_PATH, { replace: true });
pushToast({
title: "Environment variables updated",
body: `${environment.name} will inject the updated variables into future runs.`,
tone: "success",
});
},
onError: (error) => {
pushToast({
title: "Failed to save environment variables",
body: error instanceof Error ? error.message : "Environment variables save failed.",
tone: "error",
});
},
});
const environmentMutation = useMutation({
mutationFn: async (form: EnvironmentFormState) => {
const body = buildEnvironmentPayload(form);
if (editingEnvironmentId) {
return await environmentsApi.update(editingEnvironmentId, body, selectedCompanyId);
}
if (!selectedCompanyId) throw new Error("Select a company to create environments");
return await environmentsApi.create(selectedCompanyId!, body);
},
onSuccess: async (environment) => {
const wasEditing = editingEnvironmentId !== null;
if (selectedCompanyId) {
await queryClient.invalidateQueries({
queryKey: queryKeys.environments.list(selectedCompanyId),
});
}
await queryClient.invalidateQueries({
queryKey: queryKeys.environments.customImageTemplate(environment.id),
});
initializedFormKeyRef.current = null;
setEnvironmentForm(createEmptyEnvironmentForm());
setEnvironmentFormBaselineKey(null);
setEnvironmentVariablesDirty(false);
environmentMutation.reset();
draftEnvironmentProbeMutation.reset();
navigate(ENVIRONMENTS_PATH, { replace: true });
pushToast({
title: wasEditing ? "Environment updated" : "Environment created",
body: `${environment.name} is ready.`,
tone: "success",
});
const reconciliation = (environment as EnvironmentUpdateResult).customImageReconciliation;
if (reconciliation?.action === "relinked") {
pushToast({
title: "Custom image kept active",
body: "The captured image was re-linked to the updated configuration automatically.",
tone: "info",
});
} else if (reconciliation?.action === "detached") {
pushToast({
title: "Custom image no longer applies",
body: "This change alters what the captured image was built from. Runs use the base configuration until you capture a new image.",
tone: "warn",
});
}
},
onError: (error) => {
pushToast({
title: "Failed to save environment",
body: error instanceof Error ? error.message : "Environment save failed.",
tone: "error",
});
},
});
const defaultEnvironmentMutation = useMutation({
mutationFn: async (defaultEnvironmentId: string | null) =>
await instanceSettingsApi.update({ defaultEnvironmentId }),
onSuccess: async () => {
await queryClient.invalidateQueries({ queryKey: queryKeys.instance.settings });
pushToast({
title: "Default environment updated",
body: "Agent inheritance now follows the updated instance default.",
tone: "success",
});
},
onError: (error) => {
pushToast({
title: "Failed to update default environment",
body: error instanceof Error ? error.message : "Default environment update failed.",
tone: "error",
});
},
});
const deleteEnvironmentMutation = useMutation({
mutationFn: async (input: {
environment: Environment;
reassignAgentIds: string[];
reassignTargetId: string | null;
destroyReusableLeases: boolean;
}) => {
// Reassign before deleting: the FK would null the references anyway, but
// an explicit PATCH records the change in each agent's config history and
// honors the operator's chosen target instead of the implicit fallback.
for (const agentId of input.reassignAgentIds) {
await agentsApi.update(
agentId,
{ defaultEnvironmentId: input.reassignTargetId },
selectedCompanyId ?? undefined,
);
}
return input.destroyReusableLeases
? await environmentsApi.remove(input.environment.id, { destroyReusableSandboxLeases: true })
: await environmentsApi.remove(input.environment.id);
},
onSuccess: async (environment, input) => {
if (selectedCompanyId) {
await queryClient.invalidateQueries({ queryKey: queryKeys.environments.list(selectedCompanyId) });
if (input.reassignAgentIds.length > 0) {
await queryClient.invalidateQueries({ queryKey: queryKeys.agents.list(selectedCompanyId) });
}
}
queryClient.removeQueries({ queryKey: ["environment-delete-blast-radius", environment.id] });
setDeleteDialogOpen(false);
initializedFormKeyRef.current = null;
setEnvironmentForm(createEmptyEnvironmentForm());
setEnvironmentFormBaselineKey(null);
setEnvironmentVariablesDirty(false);
navigate(ENVIRONMENTS_PATH, { replace: true });
const destroyedCount = environment.destroyedReusableSandboxLeaseCount ?? 0;
pushToast({
title: "Environment deleted",
body:
destroyedCount > 0
? `${environment.name} was deleted. Destroyed ${destroyedCount === 1 ? "1 reusable sandbox" : `${destroyedCount} reusable sandboxes`}.`
: `${environment.name} was deleted.`,
tone: "success",
});
},
onError: async (error, input) => {
// Agents reassigned before the failure keep their new target; refresh so
// the dialog reflects the actual remaining usage.
if (selectedCompanyId && input.reassignAgentIds.length > 0) {
await queryClient.invalidateQueries({ queryKey: queryKeys.agents.list(selectedCompanyId) });
}
await queryClient.invalidateQueries({
queryKey: ["environment-delete-blast-radius", input.environment.id],
});
pushToast({
title: "Failed to delete environment",
body: error instanceof Error ? error.message : "Environment delete failed.",
tone: "error",
});
},
});
const environmentProbeMutation = useMutation({
mutationFn: async (environmentId: string) => await environmentsApi.probe(environmentId, selectedCompanyId),
onMutate: (environmentId) => {
setTestingEnvironmentId(environmentId);
},
onSettled: (_probe, _error, environmentId) => {
setTestingEnvironmentId((current) => (current === environmentId ? null : current));
},
onSuccess: (probe, environmentId) => {
setProbeResults((current) => ({
...current,
[environmentId]: probe,
}));
pushToast({
title: probe.ok ? "Environment probe passed" : "Environment probe failed",
body: probe.summary,
tone: probe.ok ? "success" : "error",
});
},
onError: (error, environmentId) => {
const failedEnvironment = (environments ?? []).find((environment) => environment.id === environmentId);
setProbeResults((current) => ({
...current,
[environmentId]: {
ok: false,
driver: failedEnvironment?.driver ?? "local",
summary: error instanceof Error ? error.message : "Environment probe failed.",
details: null,
},
}));
pushToast({
title: "Environment probe failed",
body: error instanceof Error ? error.message : "Environment probe failed.",
tone: "error",
});
},
});
const draftEnvironmentProbeMutation = useMutation({
mutationFn: async (form: EnvironmentFormState) => {
if (!selectedCompanyId) throw new Error("Select a company to test environments");
const body = buildEnvironmentPayload(form);
return await environmentsApi.probeConfig(selectedCompanyId, body);
},
onSuccess: (probe) => {
pushToast({
title: probe.ok ? "Draft probe passed" : "Draft probe failed",
body: probe.summary,
tone: probe.ok ? "success" : "error",
});
},
onError: (error) => {
pushToast({
title: "Draft probe failed",
body: error instanceof Error ? error.message : "Environment probe failed.",
tone: "error",
});
},
});
useEffect(() => {
initializedFormKeyRef.current = null;
setEnvironmentForm(createEmptyEnvironmentForm());
setEnvironmentFormBaselineKey(null);
setEnvironmentVariablesDirty(false);
setProbeResults({});
setTestingEnvironmentId(null);
}, [selectedCompanyId]);
const resetEnvironmentMutation = environmentMutation.reset;
const resetDraftEnvironmentProbeMutation = draftEnvironmentProbeMutation.reset;
useEffect(() => {
if (!isEnvironmentFormPage) {
initializedFormKeyRef.current = null;
setEnvironmentFormBaselineKey(null);
setEnvironmentVariablesDirty(false);
return;
}
const formKey = mode === "create"
? `create:${selectedCompanyId ?? "none"}`
: `edit:${selectedCompanyId ?? "none"}:${editingEnvironmentId ?? "missing"}`;
if (initializedFormKeyRef.current === formKey) return;
resetEnvironmentMutation();
resetDraftEnvironmentProbeMutation();
if (mode === "create") {
const emptyForm = createEmptyEnvironmentForm();
setEnvironmentForm(emptyForm);
setEnvironmentFormBaselineKey(environmentFormKey(emptyForm));
setEnvironmentVariablesDirty(false);
initializedFormKeyRef.current = formKey;
return;
}
const environment = editingEnvironmentId
? (environments ?? []).find((candidate) => candidate.id === editingEnvironmentId) ?? null
: null;
if (!environment) return;
const nextForm = createEnvironmentFormFromEnvironment(environment);
setEnvironmentForm(nextForm);
setEnvironmentFormBaselineKey(environmentFormKey(nextForm));
setEnvironmentVariablesDirty(false);
initializedFormKeyRef.current = formKey;
}, [
editingEnvironmentId,
environments,
isEnvironmentFormPage,
mode,
resetDraftEnvironmentProbeMutation,
resetEnvironmentMutation,
selectedCompanyId,
]);
function confirmDiscardEnvironmentChanges() {
return (
!environmentHasUnsavedChanges ||
typeof window === "undefined" ||
window.confirm(DISCARD_ENVIRONMENT_CHANGES_MESSAGE)
);
}
// The form page is routed, so leaving it (tab close, reload, or an in-app
// link) silently drops the draft. Intercept both exits while dirty.
useEffect(() => {
if (!environmentHasUnsavedChanges) return;
function handleBeforeUnload(event: BeforeUnloadEvent) {
event.preventDefault();
event.returnValue = "";
}
function handleDocumentClick(event: MouseEvent) {
if (
event.defaultPrevented ||
event.button !== 0 ||
event.altKey ||
event.ctrlKey ||
event.metaKey ||
event.shiftKey
) {
return;
}
const target = event.target;
if (!(target instanceof Element)) return;
const anchor = target.closest("a[href]");
if (!(anchor instanceof HTMLAnchorElement)) return;
if (anchor.target && anchor.target !== "_self") return;
const nextUrl = new URL(anchor.href, window.location.href);
const currentUrl = new URL(window.location.href);
if (nextUrl.origin !== currentUrl.origin) return;
if (
nextUrl.pathname === currentUrl.pathname &&
nextUrl.search === currentUrl.search &&
nextUrl.hash === currentUrl.hash
) {
return;
}
if (window.confirm(DISCARD_ENVIRONMENT_CHANGES_MESSAGE)) return;
event.preventDefault();
event.stopPropagation();
}
window.addEventListener("beforeunload", handleBeforeUnload);
document.addEventListener("click", handleDocumentClick, true);
return () => {
window.removeEventListener("beforeunload", handleBeforeUnload);
document.removeEventListener("click", handleDocumentClick, true);
};
}, [environmentHasUnsavedChanges]);
function closeEnvironmentForm() {
if (environmentMutation.isPending) return;
if (!confirmDiscardEnvironmentChanges()) return;
initializedFormKeyRef.current = null;
setEnvironmentForm(createEmptyEnvironmentForm());
setEnvironmentFormBaselineKey(null);
setEnvironmentVariablesDirty(false);
environmentMutation.reset();
draftEnvironmentProbeMutation.reset();
navigate(ENVIRONMENTS_PATH);
}
function flushEnvironmentForm(): EnvironmentFormState {
const flushedEnvVars = environmentVariablesEditorRef.current?.flushPendingDraft();
return flushedEnvVars ? { ...environmentForm, envVars: flushedEnvVars } : environmentForm;
}
const discoveredPluginSandboxProviders = Object.entries(environmentCapabilities?.sandboxProviders ?? {})
.filter(([provider, capability]) => provider !== "fake" && capability.supportsRunExecution)
.map(([provider, capability]) => ({
provider,
displayName: capability.displayName || provider,
description: capability.description,
configSchema: normalizeJsonSchema(capability.configSchema),
}))
.sort((left, right) => left.displayName.localeCompare(right.displayName));
const sandboxCreationEnabled = discoveredPluginSandboxProviders.length > 0;
const pluginSandboxProviders =
environmentForm.sandboxProvider.trim().length > 0 &&
environmentForm.sandboxProvider !== "fake" &&
!discoveredPluginSandboxProviders.some((provider) => provider.provider === environmentForm.sandboxProvider)
? [
...discoveredPluginSandboxProviders,
{ provider: environmentForm.sandboxProvider, displayName: environmentForm.sandboxProvider, description: undefined, configSchema: null },
]
: discoveredPluginSandboxProviders;
const selectedSandboxProvider = pluginSandboxProviders.find(
(provider) => provider.provider === environmentForm.sandboxProvider,
) ?? null;
const selectedSandboxSchema = selectedSandboxProvider?.configSchema ?? null;
const sandboxConfigErrors =
environmentForm.driver === "sandbox" && selectedSandboxSchema
? validateJsonSchemaForm(selectedSandboxSchema as any, environmentForm.sandboxConfig)
: {};
useEffect(() => {
if (environmentForm.driver !== "sandbox") return;
if (environmentForm.sandboxProvider.trim().length > 0 && environmentForm.sandboxProvider !== "fake") return;
const firstProvider = discoveredPluginSandboxProviders[0]?.provider;
if (!firstProvider) return;
const firstSchema = discoveredPluginSandboxProviders[0]?.configSchema;
setEnvironmentForm((current) => (
current.driver !== "sandbox" || (current.sandboxProvider.trim().length > 0 && current.sandboxProvider !== "fake")
? current
: {
...current,
sandboxProvider: firstProvider,
sandboxConfig: firstSchema ? getDefaultValues(firstSchema as any) : {},
}
));
}, [discoveredPluginSandboxProviders, environmentForm.driver, environmentForm.sandboxProvider]);
const environmentFormValid =
environmentForm.name.trim().length > 0 &&
(environmentForm.driver !== "ssh" ||
(
environmentForm.sshHost.trim().length > 0 &&
environmentForm.sshUsername.trim().length > 0 &&
environmentForm.sshRemoteWorkspacePath.trim().length > 0
)) &&
(environmentForm.driver !== "sandbox" ||
environmentForm.sandboxProvider.trim().length > 0 &&
environmentForm.sandboxProvider !== "fake" &&
Object.keys(sandboxConfigErrors).length === 0);
const editingEnvironment = editingEnvironmentId
? savedEnvironments.find((environment) => environment.id === editingEnvironmentId) ?? null
: null;
const editingSandboxProvider = editingEnvironment ? readEnvironmentSandboxProvider(editingEnvironment) : null;
const editingSandboxCapability = editingSandboxProvider
? environmentCapabilities?.sandboxProviders?.[editingSandboxProvider]
: null;
const editingSandboxDisplayName = editingSandboxCapability?.displayName ?? editingSandboxProvider ?? "sandbox";
const nonLocalEnvironments = savedEnvironments.filter((environment) => !isLocalEnvironment(environment));
const instanceDefaultEnvironmentId = normalizeNonLocalEnvironmentId(
instanceSettings?.defaultEnvironmentId ?? null,
savedEnvironments,
);
const instanceDefaultEnvironment =
savedEnvironments.find((environment) => environment.id === instanceDefaultEnvironmentId) ?? null;
const deleteBlastRadius = deleteBlastRadiusQuery.data ?? null;
// Reusable sandbox leases are a soft blocker: with explicit consent the
// delete destroys those sandboxes inline. Any other reason is a hard block.
const reusableLeaseOnlyBlock =
deleteBlastRadius !== null &&
deleteBlastRadius.deleteBlockedReasons.length > 0 &&
deleteBlastRadius.deleteBlockedReasons.every((reason) => reason === "reusable_sandbox_lease");
const deleteBlockMessage =
deleteBlastRadius && !reusableLeaseOnlyBlock ? environmentDeleteBlockMessage(deleteBlastRadius) : null;
const deleteUsageLoading = deleteBlastRadiusQuery.isPending || companyAgentsQuery.isPending;
const deleteUsageError = deleteBlastRadiusQuery.isError || companyAgentsQuery.isError;
// Environments are instance-scoped while the agent list is company-scoped, so
// this covers only the agents the current company context can reassign.
// References the list cannot see (other companies, terminated agents) fall
// back to the instance default via the FK's on-delete-set-null.
const agentsUsingEnvironment = editingEnvironmentId
? (companyAgentsQuery.data ?? []).filter(
(agent) => agent.status !== "terminated" && agent.defaultEnvironmentId === editingEnvironmentId,
)
: [];
const reassignTargetEnvironments = nonLocalEnvironments.filter(
(environment) => environment.id !== editingEnvironmentId,
);
const deleteImpactNotes: string[] = [];
if (deleteBlastRadius && !deleteBlockMessage) {
if (deleteBlastRadius.staticReferences.agentDefaultCount > agentsUsingEnvironment.length) {
deleteImpactNotes.push(
"Other references to this environment (agents in other organizations or terminated agents) fall back to the instance default.",
);
}
const selectionCount =
deleteBlastRadius.staticReferences.executionWorkspaceSelectionCount +
deleteBlastRadius.staticReferences.issueSelectionCount +
deleteBlastRadius.staticReferences.projectSelectionCount;
if (selectionCount > 0) {
deleteImpactNotes.push(
`${selectionCount} workspace, issue, or project environment ${selectionCount === 1 ? "selection" : "selections"} will be cleared.`,
);
}
if (deleteBlastRadius.staticReferences.secretBindingCount > 0) {
deleteImpactNotes.push(
`${deleteBlastRadius.staticReferences.secretBindingCount} secret ${deleteBlastRadius.staticReferences.secretBindingCount === 1 ? "binding" : "bindings"} will be removed.`,
);
}
if (deleteBlastRadius.activeRuntimeUse.hasActiveRuntimeUse) {
deleteImpactNotes.push("Active runs or sandbox leases currently resolve to this environment.");
}
}
// One row per workspace holding blocking sandbox leases (several leases can
// share a workspace). A lease whose workspace FK was nulled groups alone.
const reusableLeaseHolderGroups = (() => {
const groups = new Map<
string,
{ workspaceId: string | null; label: string; issueLabels: string[]; leaseCount: number }
>();
for (const holder of deleteBlastRadius?.reusableSandboxLeaseHolders ?? []) {
const key = holder.executionWorkspaceId ?? `lease:${holder.leaseId}`;
const issueLabel = holder.issueIdentifier ?? holder.issueTitle;
const existing = groups.get(key);
if (existing) {
existing.leaseCount += 1;
if (issueLabel && !existing.issueLabels.includes(issueLabel)) existing.issueLabels.push(issueLabel);
} else {
groups.set(key, {
workspaceId: holder.executionWorkspaceId,
label:
holder.executionWorkspaceName
?? holder.issueIdentifier
?? holder.issueTitle
?? "Workspace no longer on record",
issueLabels: issueLabel ? [issueLabel] : [],
leaseCount: 1,
});
}
}
return Array.from(groups.values());
})();
if (!selectedCompanyId) {
return <div className="text-sm text-muted-foreground">Select an organization context to manage environment secrets and bindings.</div>;
}
if (!environmentsEnabled) {
return (
<div className="max-w-6xl space-y-4">
<div className="text-sm text-muted-foreground">
Enable Environments in instance experimental settings to manage shared execution targets.
</div>
</div>
);
}
return (
<div className="max-w-6xl space-y-6" data-testid="instance-settings-environments-section">
{!isEnvironmentFormPage ? (
<div className="space-y-4">
<div className="flex flex-wrap items-center justify-between gap-3">
<label className="flex flex-wrap items-center gap-3 text-sm font-medium">
<span>Default</span>
<span>
<select
aria-label="Default environment"
className="min-w-(--sz-12rem) max-w-full rounded-md border border-border bg-transparent px-2.5 py-1.5 text-sm font-normal outline-none"
value={instanceDefaultEnvironmentId}
onChange={(event) =>
defaultEnvironmentMutation.mutate(event.target.value || null)}
disabled={defaultEnvironmentMutation.isPending}
>
{managedSandboxOnly ? (
// Managed-sandbox-only instances never execute locally, so
// the implicit local fallback is not a legal default. The
// placeholder only renders while no default is stamped yet.
instanceDefaultEnvironmentId === "" ? (
<option value="" disabled>
Select environment
</option>
) : null
) : (
<option value="">Local</option>
)}
{nonLocalEnvironments.map((environment) => (
<option key={environment.id} value={environment.id}>
{environmentDisplayLabel(environment)}
</option>
))}
</select>
</span>
</label>
<Button size="icon-sm" variant="ghost" asChild>
<Link to={`${ENVIRONMENTS_PATH}/new`} aria-label="Add environment" title="Add environment">
<Plus className="h-4 w-4" />
</Link>
</Button>
</div>
<div className="space-y-1">
{savedEnvironments.map((environment) => {
const probe = probeResults[environment.id] ?? null;
const sandboxProvider = readEnvironmentSandboxProvider(environment);
const sandboxProviderCapability = sandboxProvider
? environmentCapabilities?.sandboxProviders?.[sandboxProvider]
: null;
const sandboxProviderDisplayName =
sandboxProviderCapability?.displayName ?? sandboxProvider ?? "sandbox";
return (
<div
key={environment.id}
className="py-3"
>
<div className="flex flex-wrap items-start justify-between gap-3">
<div className="space-y-1">
<div className="flex flex-wrap items-center gap-2 text-sm font-medium">
<span>
{environment.name}
{isPlatformManagedEnvironment(environment) ? null : (
<span className="text-muted-foreground"> · {environment.driver}</span>
)}
</span>
{isPlatformManagedEnvironment(environment) ? (
<span className="inline-flex items-center gap-1 rounded-full bg-muted px-2 py-0.5 text-xs font-normal text-muted-foreground">
<Lock className="h-3 w-3" aria-hidden />
Managed by Paperclip
</span>
) : null}
</div>
{environment.description ? (
<div className="text-xs text-muted-foreground">{environment.description}</div>
) : null}
{environment.driver === "ssh" ? (
<div className="text-xs text-muted-foreground">
{typeof environment.config.host === "string" ? environment.config.host : "SSH host"} ·{" "}
{typeof environment.config.username === "string" ? environment.config.username : "user"}
</div>
) : environment.driver === "sandbox" ? (
<div className="text-xs text-muted-foreground">
{(() => {
const summary = summarizeSandboxConfig(environment.config as Record<string, unknown>);
// The managed row's badge already says "Managed by
// Paperclip"; repeating provider vocabulary like
// "sandbox provider" next to the default environment
// is noise the product avoids.
if (isPlatformManagedEnvironment(environment)) {
return summary ?? "Provisioned and maintained for you.";
}
return `${sandboxProviderDisplayName} sandbox provider${summary ? ` · ${summary}` : ""}`;
})()}
</div>
) : (
<div className="text-xs text-muted-foreground">Runs on this Paperclip host.</div>
)}
</div>
<div className="flex flex-wrap items-center gap-2">
{environment.driver !== "local" ? (
<Button
size="sm"
variant="outline"
onClick={() => environmentProbeMutation.mutate(environment.id)}
disabled={testingEnvironmentId === environment.id}
>
{testingEnvironmentId === environment.id
? "Testing..."
: environment.driver === "ssh"
? "Test connection"
: "Test provider"}
</Button>
) : null}
<Button size="sm" variant="ghost" asChild>
<Link to={environmentEditPath(environment.id)}>Edit</Link>
</Button>
</div>
</div>
{probe ? (
<div
className={
probe.ok
? "mt-3 rounded bg-green-500/5 px-2.5 py-2 text-xs text-green-700"
: "mt-3 rounded bg-destructive/5 px-2.5 py-2 text-xs text-destructive"
}
>
<div className="font-medium">{probe.summary}</div>
{probe.details?.error && typeof probe.details.error === "string" ? (
<div className="mt-1 font-mono text-(length:--text-micro)">{probe.details.error}</div>
) : null}
</div>
) : null}
</div>
);
})}
</div>
</div>
) : null}
{isEnvironmentFormPage && mode === "edit" && environments === undefined ? (
<div className="text-sm text-muted-foreground">
Loading environment...
</div>
) : null}
{isEnvironmentFormPage && mode === "edit" && environments !== undefined && !editingEnvironment ? (
<div className="space-y-3 text-sm">
<div className="font-medium">Environment not found</div>
<div className="text-muted-foreground">The environment may have been removed or is not available in this organization.</div>
<Button size="sm" variant="outline" asChild>
<Link to={ENVIRONMENTS_PATH}>Back to environments</Link>
</Button>
</div>
) : null}
{isEnvironmentFormPage && mode === "edit" && editingEnvironment && isPlatformManagedEnvironment(editingEnvironment) ? (
<SecretRefHintsContext.Provider value={environmentSecretRefHints}>
<div data-testid="managed-environment-form-page">
<div className="pb-4">
<div className="mb-4">
<Button size="sm" variant="ghost" asChild>
<Link to={ENVIRONMENTS_PATH}>
<ArrowLeft className="mr-1.5 h-3.5 w-3.5" />
Environments
</Link>
</Button>
</div>
<div className="flex flex-wrap items-center gap-2">
<h1 className="text-lg font-semibold">{editingEnvironment.name}</h1>
<span className="inline-flex items-center gap-1 rounded-full bg-muted px-2 py-0.5 text-xs text-muted-foreground">
<Lock className="h-3 w-3" aria-hidden />
Managed by Paperclip
</span>
</div>
<p className="mt-1 max-w-3xl text-sm text-muted-foreground">
{editingEnvironment.description ?? "Your agent runs on a computer managed by Paperclip."}
</p>
<p className="mt-1 max-w-3xl text-xs text-muted-foreground">
This environment is provisioned and maintained for you. You can add environment
variables for your agents; its name and configuration are managed by Paperclip.
</p>
</div>
<div className="py-4">
<Field
label="Environment variables"
hint="Injected into runs that resolve through this environment. Use plain values or organization secrets."
>
<EnvironmentVariablesEditor
ref={environmentVariablesEditorRef}
value={environmentForm.envVars}
secrets={secrets ?? []}
onCreateSecret={async (name, value) => await createSecret.mutateAsync({ name, value })}
onChange={(env) =>
setEnvironmentForm((current) => ({ ...current, envVars: env ?? {} }))}
onDirtyChange={setEnvironmentVariablesDirty}
/>
</Field>
{managedEnvironmentEnvVarsMutation.isError ? (
<div className="mt-3 text-xs text-destructive">
{managedEnvironmentEnvVarsMutation.error instanceof Error
? managedEnvironmentEnvVarsMutation.error.message
: "Failed to save environment variables"}
</div>
) : null}
</div>
<div className="flex flex-wrap justify-end gap-2 py-4">
<Button
variant="outline"
onClick={closeEnvironmentForm}
disabled={managedEnvironmentEnvVarsMutation.isPending}
>
Cancel
</Button>
<Button
onClick={() => managedEnvironmentEnvVarsMutation.mutate(flushEnvironmentForm().envVars)}
disabled={managedEnvironmentEnvVarsMutation.isPending}
>
{managedEnvironmentEnvVarsMutation.isPending ? "Saving..." : "Save environment variables"}
</Button>
</div>
</div>
</SecretRefHintsContext.Provider>
) : null}
{isEnvironmentFormPage &&
(mode === "create" || (editingEnvironment && !isPlatformManagedEnvironment(editingEnvironment))) ? (
<SecretRefHintsContext.Provider value={environmentSecretRefHints}>
<div data-testid="environment-form-page">
<div className="pb-4">
<div className="mb-4 flex items-center justify-between gap-2">
<Button size="sm" variant="ghost" asChild>
<Link to={ENVIRONMENTS_PATH}>
<ArrowLeft className="mr-1.5 h-3.5 w-3.5" />
Environments
</Link>
</Button>
{editingEnvironment ? (
<Button
size="icon-sm"
variant="ghost"
className="text-muted-foreground hover:text-destructive"
aria-label={`Delete ${editingEnvironment.name}`}
title="Delete environment"
data-testid="environment-delete-button"
onClick={() => {
setReassignEnvironmentTargetId("");
setDeleteDialogOpen(true);
}}
>
<Trash2 className="h-4 w-4" />
</Button>
) : null}
</div>
<h1 className="text-lg font-semibold">{editingEnvironmentId ? "Edit environment" : "Add environment"}</h1>
<p className="mt-1 max-w-3xl text-sm text-muted-foreground">
Configure a reusable execution target for your agents. Saved changes affect future runs; Paperclip may start fresh sessions or sandbox leases after environment config changes.
</p>
</div>
<div className="py-4">
<div className="space-y-4">
<Field label="Name" hint="Operator-facing name for this execution target.">
<input
className="w-full rounded-md border border-border bg-transparent px-2.5 py-1.5 text-sm outline-none"
type="text"
value={environmentForm.name}
onChange={(e) => setEnvironmentForm((current) => ({ ...current, name: e.target.value }))}
/>
</Field>
<Field label="Description" hint="Optional note about what this machine is for.">
<input
className="w-full rounded-md border border-border bg-transparent px-2.5 py-1.5 text-sm outline-none"
type="text"
value={environmentForm.description}
onChange={(e) => setEnvironmentForm((current) => ({ ...current, description: e.target.value }))}
/>
</Field>
<Field label="Driver" hint="Sandbox stores plugin-backed provider config on the shared environment seam. SSH stores a remote machine target.">
<select
className="w-full rounded-md border border-border bg-transparent px-2.5 py-1.5 text-sm outline-none"
value={environmentForm.driver}
onChange={(e) =>
setEnvironmentForm((current) => ({
...current,
sandboxProvider:
e.target.value === "sandbox"
? current.sandboxProvider.trim() || discoveredPluginSandboxProviders[0]?.provider || ""
: current.sandboxProvider,
sandboxConfig:
e.target.value === "sandbox"
? (
current.sandboxProvider.trim().length > 0 && current.driver === "sandbox"
? current.sandboxConfig
: discoveredPluginSandboxProviders[0]?.configSchema
? getDefaultValues(discoveredPluginSandboxProviders[0].configSchema as any)
: {}
)
: current.sandboxConfig,
driver: e.target.value === "sandbox" ? "sandbox" : "ssh",
}))}
>
{sandboxCreationEnabled || environmentForm.driver === "sandbox" ? (
<option value="sandbox">Sandbox</option>
) : null}
<option value="ssh">SSH</option>
{environmentForm.driver === "local" ? (
<option value="local">Local</option>
) : null}
</select>
</Field>
{environmentForm.driver === "ssh" ? (
<div className="grid gap-3 md:grid-cols-2">
<Field label="Host" hint="DNS name or IP address for the remote machine.">
<input
className="w-full rounded-md border border-border bg-transparent px-2.5 py-1.5 text-sm outline-none"
type="text"
value={environmentForm.sshHost}
onChange={(e) => setEnvironmentForm((current) => ({ ...current, sshHost: e.target.value }))}
/>
</Field>
<Field label="Port" hint="Defaults to 22.">
<input
className="w-full rounded-md border border-border bg-transparent px-2.5 py-1.5 text-sm outline-none"
type="number"
min={1}
max={65535}
value={environmentForm.sshPort}
onChange={(e) => setEnvironmentForm((current) => ({ ...current, sshPort: e.target.value }))}
/>
</Field>
<Field label="Username" hint="SSH username.">
<input
className="w-full rounded-md border border-border bg-transparent px-2.5 py-1.5 text-sm outline-none"
type="text"
value={environmentForm.sshUsername}
onChange={(e) => setEnvironmentForm((current) => ({ ...current, sshUsername: e.target.value }))}
/>
</Field>
{/*
This path lives on the user's own remote SSH host, not on a
Paperclip execution host, so it stays visible under the
managed-sandbox-only policy. The policy hides host paths that
the platform-managed environment owns; an SSH environment the
user configured is outside that contract.
*/}
<Field label="Remote workspace path" hint="Absolute path that Paperclip will verify during SSH connection tests.">
<input
className="w-full rounded-md border border-border bg-transparent px-2.5 py-1.5 text-sm outline-none"
type="text"
placeholder="/Users/paperclip/workspace"
value={environmentForm.sshRemoteWorkspacePath}
onChange={(e) =>
setEnvironmentForm((current) => ({ ...current, sshRemoteWorkspacePath: e.target.value }))}
/>
</Field>
<Field label="Private key" hint="Optional PEM private key. Leave blank to rely on the server's SSH agent or default keychain.">
<div className="space-y-2">
<select
className="w-full rounded-md border border-border bg-transparent px-2.5 py-1.5 text-sm outline-none"
value={environmentForm.sshPrivateKeySecretId}
onChange={(e) =>
setEnvironmentForm((current) => ({
...current,
sshPrivateKeySecretId: e.target.value,
sshPrivateKey: e.target.value ? "" : current.sshPrivateKey,
}))}
>
<option value="">No saved secret</option>
{(secrets ?? []).map((secret) => (
<option key={secret.id} value={secret.id}>{secret.name}</option>
))}
</select>
<textarea
className="h-32 w-full rounded-md border border-border bg-transparent px-2.5 py-1.5 text-xs font-mono outline-none"
value={environmentForm.sshPrivateKey}
disabled={!!environmentForm.sshPrivateKeySecretId}
onChange={(e) => setEnvironmentForm((current) => ({ ...current, sshPrivateKey: e.target.value }))}
/>
</div>
</Field>
<Field label="Known hosts" hint="Optional known_hosts block used when strict host key checking is enabled.">
<textarea
className="h-32 w-full rounded-md border border-border bg-transparent px-2.5 py-1.5 text-xs font-mono outline-none"
value={environmentForm.sshKnownHosts}
onChange={(e) => setEnvironmentForm((current) => ({ ...current, sshKnownHosts: e.target.value }))}
/>
</Field>
<div className="md:col-span-2">
<ToggleField
label="Strict host key checking"
hint="Keep this on unless you deliberately want probe-time host key acceptance disabled."
checked={environmentForm.sshStrictHostKeyChecking}
onChange={(checked) =>
setEnvironmentForm((current) => ({ ...current, sshStrictHostKeyChecking: checked }))}
/>
</div>
</div>
) : null}
{environmentForm.driver === "sandbox" ? (
<div className="space-y-3">
<Field label="Provider" hint="Installed run-capable sandbox provider plugins appear here.">
<select
className="w-full rounded-md border border-border bg-transparent px-2.5 py-1.5 text-sm outline-none"
value={environmentForm.sandboxProvider}
onChange={(e) => {
const nextProviderKey = e.target.value;
const nextProvider = pluginSandboxProviders.find((provider) => provider.provider === nextProviderKey) ?? null;
setEnvironmentForm((current) => ({
...current,
sandboxProvider: nextProviderKey,
sandboxConfig:
current.sandboxProvider === nextProviderKey
? current.sandboxConfig
: nextProvider?.configSchema
? getDefaultValues(nextProvider.configSchema as any)
: {},
}));
}}
>
{pluginSandboxProviders.map((provider) => (
<option key={provider.provider} value={provider.provider}>
{provider.displayName}
</option>
))}
</select>
</Field>
{selectedSandboxProvider?.description ? (
<div className="text-xs text-muted-foreground">
{selectedSandboxProvider.description}
</div>
) : null}
{selectedSandboxSchema ? (
<JsonSchemaForm
schema={selectedSandboxSchema as any}
values={environmentForm.sandboxConfig}
onChange={(values) =>
setEnvironmentForm((current) => ({ ...current, sandboxConfig: values }))}
errors={sandboxConfigErrors}
/>
) : (
<div className="text-xs text-muted-foreground">
This provider does not declare additional configuration fields.
</div>
)}
<ToggleField
label="Stream run logs"
hint="Stream the agent CLI's output live while runs execute (recommended). Turn off to deliver output only when the run finishes."
checked={environmentForm.sandboxConfig.streamRunLogs !== false}
onChange={(checked) =>
setEnvironmentForm((current) => ({
...current,
sandboxConfig: { ...current.sandboxConfig, streamRunLogs: checked },
}))}
/>
</div>
) : null}
{editingEnvironment &&
editingEnvironment.driver === "sandbox" &&
environmentForm.driver === "sandbox" &&
selectedCompanyId ? (
<div className="space-y-2 py-3">
<div className="text-sm font-medium">Custom image</div>
<div className="text-xs text-muted-foreground">
Start a setup sandbox, SSH in to customize the instance, then capture the
running machine as a reusable image for future runs.
</div>
<EnvironmentImageTemplatePanel
environment={editingEnvironment}
companyId={selectedCompanyId}
providerCapability={editingSandboxCapability}
providerDisplayName={editingSandboxDisplayName}
/>
</div>
) : null}
<Field
label="Environment variables"
hint="Injected into runs that resolve through this environment. Use plain values or organization secrets."
>
<EnvironmentVariablesEditor
ref={environmentVariablesEditorRef}
value={environmentForm.envVars}
secrets={secrets ?? []}
onCreateSecret={async (name, value) => await createSecret.mutateAsync({ name, value })}
onChange={(env) =>
setEnvironmentForm((current) => ({ ...current, envVars: env ?? {} }))}
onDirtyChange={setEnvironmentVariablesDirty}
/>
</Field>
{environmentMutation.isError ? (
<div className="text-xs text-destructive">
{environmentMutation.error instanceof Error
? environmentMutation.error.message
: "Failed to save environment"}
</div>
) : null}
{draftEnvironmentProbeMutation.data ? (
<div className={draftEnvironmentProbeMutation.data.ok ? "text-xs text-green-600" : "text-xs text-destructive"}>
{draftEnvironmentProbeMutation.data.summary}
</div>
) : null}
</div>
</div>
<div className="flex flex-wrap justify-end gap-2 py-4">
<Button
variant="outline"
onClick={closeEnvironmentForm}
disabled={environmentMutation.isPending}
>
Cancel
</Button>
{environmentForm.driver !== "local" ? (
<Button
variant="outline"
onClick={() => draftEnvironmentProbeMutation.mutate(flushEnvironmentForm())}
disabled={draftEnvironmentProbeMutation.isPending || !environmentFormValid}
>
{draftEnvironmentProbeMutation.isPending ? "Testing..." : "Test"}
</Button>
) : null}
<Button
onClick={() => environmentMutation.mutate(flushEnvironmentForm())}
disabled={environmentMutation.isPending || !environmentFormValid}
>
{environmentMutation.isPending
? editingEnvironmentId
? "Saving..."
: "Creating..."
: editingEnvironmentId
? "Save environment"
: "Create environment"}
</Button>
</div>
{editingEnvironment ? (
<AlertDialog
open={deleteDialogOpen}
onOpenChange={(open) => {
if (!open && deleteEnvironmentMutation.isPending) return;
setDeleteDialogOpen(open);
}}
>
<AlertDialogContent data-testid="environment-delete-dialog">
<AlertDialogHeader>
<AlertDialogTitle>Delete {editingEnvironment.name}?</AlertDialogTitle>
<AlertDialogDescription>
{deleteUsageLoading
? "Checking what uses this environment..."
: deleteUsageError
? "Could not check what uses this environment. Close this dialog and retry."
: deleteBlockMessage
?? ([
reusableLeaseOnlyBlock && deleteBlastRadius
? `${deleteBlastRadius.reusableSandboxLeaseCount === 1 ? "1 reusable sandbox" : `${deleteBlastRadius.reusableSandboxLeaseCount} reusable sandboxes`} will be destroyed; the workspaces holding them stay open and provision a fresh sandbox on their next run.`
: null,
agentsUsingEnvironment.length > 0
? `${agentsUsingEnvironment.length === 1 ? "1 agent uses" : `${agentsUsingEnvironment.length} agents use`} this environment as their default. Choose the environment those agents should be reassigned to.`
: null,
]
.filter(Boolean)
.join(" ")
|| "This environment will be permanently deleted and future runs stop resolving to it.")}
</AlertDialogDescription>
</AlertDialogHeader>
{reusableLeaseHolderGroups.length > 0 ? (
<div className="space-y-1.5" data-testid="environment-delete-lease-holders">
<div className="text-xs font-medium text-muted-foreground">Sandbox leases held by</div>
<ul className="space-y-1">
{reusableLeaseHolderGroups.map((group) => (
<li key={group.workspaceId ?? group.label} className="text-sm">
{group.workspaceId ? (
<Link
className="underline underline-offset-2 hover:text-foreground"
to={`/execution-workspaces/${group.workspaceId}`}
>
{group.label}
</Link>
) : (
<span>{group.label}</span>
)}
<span className="text-xs text-muted-foreground">
{" "}
· {group.leaseCount === 1 ? "1 sandbox lease" : `${group.leaseCount} sandbox leases`}
{group.issueLabels.length > 0 ? ` · ${group.issueLabels.join(", ")}` : ""}
</span>
</li>
))}
</ul>
<div className="text-xs text-muted-foreground">
{reusableLeaseOnlyBlock
? "Deleting destroys these sandboxes; the workspaces stay open."
: "Close these workspaces to let Paperclip destroy their sandboxes, then retry the delete."}
</div>
</div>
) : null}
{!deleteUsageLoading && !deleteUsageError && !deleteBlockMessage ? (
<div className="space-y-3">
{agentsUsingEnvironment.length > 0 ? (
<label className="block space-y-1.5 text-sm">
<span className="font-medium">
Reassign {agentsUsingEnvironment.length === 1 ? "agent" : "agents"} to
</span>
<select
aria-label="Reassign agents to environment"
data-testid="environment-delete-reassign-select"
className="w-full rounded-md border border-border bg-transparent px-2.5 py-1.5 text-sm font-normal outline-none"
value={reassignEnvironmentTargetId}
onChange={(event) => setReassignEnvironmentTargetId(event.target.value)}
>
<option value="">
Default: {instanceDefaultEnvironment
? `${instanceDefaultEnvironment.name} · ${instanceDefaultEnvironment.driver}`
: "Local"}
</option>
{reassignTargetEnvironments.map((environment) => (
<option key={environment.id} value={environment.id}>
{environment.name} · {environment.driver}
</option>
))}
</select>
<span className="block text-xs text-muted-foreground">
Affected: {agentsUsingEnvironment.map((agent) => agent.name).join(", ")}
</span>
</label>
) : null}
{deleteImpactNotes.length > 0 ? (
<ul className="space-y-1 text-xs text-muted-foreground">
{deleteImpactNotes.map((note) => (
<li key={note}>{note}</li>
))}
</ul>
) : null}
</div>
) : null}
<AlertDialogFooter>
<AlertDialogCancel disabled={deleteEnvironmentMutation.isPending}>Cancel</AlertDialogCancel>
<AlertDialogAction
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
data-testid="environment-delete-confirm"
disabled={
deleteEnvironmentMutation.isPending ||
deleteUsageLoading ||
deleteUsageError ||
Boolean(deleteBlockMessage)
}
onClick={(event) => {
event.preventDefault();
deleteEnvironmentMutation.mutate({
environment: editingEnvironment,
reassignAgentIds: agentsUsingEnvironment.map((agent) => agent.id),
reassignTargetId: reassignEnvironmentTargetId || null,
destroyReusableLeases: reusableLeaseOnlyBlock,
});
}}
>
{deleteEnvironmentMutation.isPending
? "Deleting..."
: reusableLeaseOnlyBlock && deleteBlastRadius
? `Destroy ${deleteBlastRadius.reusableSandboxLeaseCount === 1 ? "1 sandbox" : `${deleteBlastRadius.reusableSandboxLeaseCount} sandboxes`} and delete`
: "Delete environment"}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
) : null}
</div>
</SecretRefHintsContext.Provider>
) : null}
</div>
);
}