mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-29 21:07:40 +01:00
## 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
2633 lines
108 KiB
TypeScript
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>
|
|
);
|
|
}
|