From 5e3e5f3cbb3e43b7a2cb1cd8c11f2a53176930a6 Mon Sep 17 00:00:00 2001 From: Alessandro Pogliaghi Date: Mon, 6 Jul 2026 13:39:28 +0100 Subject: [PATCH] feat(cloud): custom sandbox base images --- .../src/adapters/claude/session/options.ts | 4 +- packages/api-client/src/posthog-client.ts | 161 +++++ .../src/settings/sandboxEnvironmentForm.ts | 4 + packages/core/src/sidebar/groupTasks.test.ts | 31 + packages/core/src/sidebar/groupTasks.ts | 36 +- .../src/task-detail/taskCreationApiClient.ts | 1 + .../core/src/task-detail/taskCreationSaga.ts | 1 + packages/core/src/task-detail/taskInput.ts | 2 + packages/shared/src/domain-types.ts | 41 ++ packages/shared/src/task-creation-domain.ts | 1 + .../components/CloudGitInteractionHeader.tsx | 1 + .../integrations/useCloudRepoPicker.ts | 52 ++ .../components/ImageBuilderBuildButton.tsx | 44 ++ .../sessions/components/SessionFooter.tsx | 4 + .../CloudEnvironmentsSettings.tsx | 656 +++++++++++++++++- .../environments/imageBuildWatcher.ts | 91 +++ .../environments/useSandboxCustomImages.ts | 151 ++++ .../sidebar/components/TaskListView.tsx | 10 +- .../task-detail/components/TaskInput.tsx | 9 + .../components/WorkspaceModeSelect.tsx | 84 ++- .../task-detail/hooks/useTaskCreation.ts | 4 + 21 files changed, 1368 insertions(+), 20 deletions(-) create mode 100644 packages/ui/src/features/integrations/useCloudRepoPicker.ts create mode 100644 packages/ui/src/features/sessions/components/ImageBuilderBuildButton.tsx create mode 100644 packages/ui/src/features/settings/sections/environments/imageBuildWatcher.ts create mode 100644 packages/ui/src/features/settings/sections/environments/useSandboxCustomImages.ts diff --git a/packages/agent/src/adapters/claude/session/options.ts b/packages/agent/src/adapters/claude/session/options.ts index 2c611709c2..283be25026 100644 --- a/packages/agent/src/adapters/claude/session/options.ts +++ b/packages/agent/src/adapters/claude/session/options.ts @@ -183,7 +183,9 @@ function buildEnvironment(gateway?: GatewayEnv): Record { }), ...(gateway?.openaiBaseUrl && { OPENAI_BASE_URL: gateway.openaiBaseUrl }), ...(gateway?.openaiApiKey && { OPENAI_API_KEY: gateway.openaiApiKey }), - ELECTRON_RUN_AS_NODE: "1", + ...((process.versions.electron || process.env.ELECTRON_RUN_AS_NODE) && { + ELECTRON_RUN_AS_NODE: "1", + }), CLAUDE_CODE_ENABLE_ASK_USER_QUESTION_TOOL: "true", // Offload all MCP tools by default ENABLE_TOOL_SEARCH: "auto:0", diff --git a/packages/api-client/src/posthog-client.ts b/packages/api-client/src/posthog-client.ts index e359023d56..759a888e57 100644 --- a/packages/api-client/src/posthog-client.ts +++ b/packages/api-client/src/posthog-client.ts @@ -52,6 +52,7 @@ import type { PriorityJudgmentArtefact, RepoSelectionArtefact, SafetyJudgmentArtefact, + SandboxCustomImage, SandboxEnvironment, SandboxEnvironmentInput, Signal, @@ -119,6 +120,13 @@ export class SeatPaymentFailedError extends Error { } } +export class SandboxCustomImagesDisabledError extends Error { + constructor(message?: string) { + super(message ?? "Custom sandbox images are not enabled"); + this.name = "SandboxCustomImagesDisabledError"; + } +} + export type UsageLimitType = "burst" | "sustained" | null; // Stable message so callers recognize this after a saga reduces the error to a string. @@ -477,6 +485,7 @@ interface CloudRunOptions { model?: string; reasoningLevel?: string; sandboxEnvironmentId?: string; + customImageId?: string; prAuthorshipMode?: PrAuthorshipMode; runSource?: CloudRunSource; signalReportId?: string; @@ -548,6 +557,9 @@ function buildCloudRunRequestBody( if (options?.sandboxEnvironmentId) { body.sandbox_environment_id = options.sandboxEnvironmentId; } + if (options?.customImageId) { + body.custom_image_id = options.customImageId; + } if (options?.prAuthorshipMode) { body.pr_authorship_mode = options.prAuthorshipMode; } @@ -4404,6 +4416,155 @@ export class PostHogAPIClient { } } + async listSandboxCustomImages(): Promise { + const teamId = await this.getTeamId(); + const url = new URL( + `${this.api.baseUrl}/api/projects/${teamId}/sandbox_custom_images/`, + ); + const response = await this.api.fetcher.fetch({ + method: "get", + url, + path: `/api/projects/${teamId}/sandbox_custom_images/`, + }); + if (!response.ok) { + if (response.status === 403) { + const errorData = (await response.json().catch(() => ({}))) as { + detail?: string; + }; + throw new SandboxCustomImagesDisabledError(errorData.detail); + } + throw new Error( + `Failed to fetch sandbox custom images: ${response.statusText}`, + ); + } + const data = (await response.json()) as { + results?: SandboxCustomImage[]; + }; + return data.results ?? []; + } + + async createSandboxCustomImage(input: { + name: string; + description?: string; + repository?: string | null; + private?: boolean; + }): Promise { + const teamId = await this.getTeamId(); + const url = new URL( + `${this.api.baseUrl}/api/projects/${teamId}/sandbox_custom_images/`, + ); + const response = await this.api.fetcher.fetch({ + method: "post", + url, + path: `/api/projects/${teamId}/sandbox_custom_images/`, + overrides: { + body: JSON.stringify(input), + }, + }); + if (!response.ok) { + const errorData = (await response.json().catch(() => ({}))) as { + detail?: string; + }; + throw new Error( + errorData.detail ?? + `Failed to create sandbox custom image: ${response.statusText}`, + ); + } + return (await response.json()) as SandboxCustomImage; + } + + async getSandboxCustomImage(id: string): Promise { + const teamId = await this.getTeamId(); + const url = new URL( + `${this.api.baseUrl}/api/projects/${teamId}/sandbox_custom_images/${id}/`, + ); + const response = await this.api.fetcher.fetch({ + method: "get", + url, + path: `/api/projects/${teamId}/sandbox_custom_images/${id}/`, + }); + if (!response.ok) { + throw new Error( + `Failed to fetch sandbox custom image: ${response.statusText}`, + ); + } + return (await response.json()) as SandboxCustomImage; + } + + async ensureSandboxCustomImageBuilderTask( + id: string, + ): Promise { + const teamId = await this.getTeamId(); + const url = new URL( + `${this.api.baseUrl}/api/projects/${teamId}/sandbox_custom_images/${id}/builder_task/`, + ); + const response = await this.api.fetcher.fetch({ + method: "post", + url, + path: `/api/projects/${teamId}/sandbox_custom_images/${id}/builder_task/`, + overrides: { + body: JSON.stringify({}), + }, + }); + if (!response.ok) { + const errorData = (await response.json().catch(() => ({}))) as { + detail?: string; + }; + throw new Error( + errorData.detail ?? + `Failed to open image builder session: ${response.statusText}`, + ); + } + return (await response.json()) as SandboxCustomImage; + } + + async buildSandboxCustomImage( + id: string, + specYaml?: string | null, + ): Promise { + const teamId = await this.getTeamId(); + const url = new URL( + `${this.api.baseUrl}/api/projects/${teamId}/sandbox_custom_images/${id}/build/`, + ); + const response = await this.api.fetcher.fetch({ + method: "post", + url, + path: `/api/projects/${teamId}/sandbox_custom_images/${id}/build/`, + overrides: { + body: JSON.stringify( + specYaml === undefined ? {} : { spec_yaml: specYaml }, + ), + }, + }); + if (!response.ok) { + const errorData = (await response.json().catch(() => ({}))) as { + detail?: string; + }; + throw new Error( + errorData.detail ?? + `Failed to build sandbox custom image: ${response.statusText}`, + ); + } + return (await response.json()) as SandboxCustomImage; + } + + async deleteSandboxCustomImage(id: string): Promise { + const teamId = await this.getTeamId(); + const url = new URL( + `${this.api.baseUrl}/api/projects/${teamId}/sandbox_custom_images/${id}/`, + ); + const response = await this.api.fetcher.fetch({ + method: "delete", + url, + path: `/api/projects/${teamId}/sandbox_custom_images/${id}/`, + }); + if (!response.ok) { + throw new Error( + `Failed to delete sandbox custom image: ${response.statusText}`, + ); + } + } + /** Find an exported asset by session recording ID. */ async findExportBySessionRecordingId( projectId: number, diff --git a/packages/core/src/settings/sandboxEnvironmentForm.ts b/packages/core/src/settings/sandboxEnvironmentForm.ts index 891e2a42c7..0ba64bcc79 100644 --- a/packages/core/src/settings/sandboxEnvironmentForm.ts +++ b/packages/core/src/settings/sandboxEnvironmentForm.ts @@ -15,6 +15,7 @@ export interface SandboxEnvironmentFormState { include_default_domains: boolean; environment_variables_text: string; private: boolean; + custom_image_id: string | null; } export function isValidDomain(domain: string): boolean { @@ -71,6 +72,7 @@ export function emptyForm(): SandboxEnvironmentFormState { include_default_domains: true, environment_variables_text: "", private: true, + custom_image_id: null, }; } @@ -84,6 +86,7 @@ export function formFromEnv( include_default_domains: env.include_default_domains, environment_variables_text: "", private: env.private, + custom_image_id: env.custom_image_id ?? null, }; } @@ -100,6 +103,7 @@ export function buildSandboxEnvironmentInput( include_default_domains: isCustom ? form.include_default_domains : false, private: form.private, repositories: [], + custom_image_id: form.custom_image_id, ...(form.environment_variables_text.trim() ? { environment_variables: envVars } : {}), diff --git a/packages/core/src/sidebar/groupTasks.test.ts b/packages/core/src/sidebar/groupTasks.test.ts index 4e4c447da1..0782db7d73 100644 --- a/packages/core/src/sidebar/groupTasks.test.ts +++ b/packages/core/src/sidebar/groupTasks.test.ts @@ -220,6 +220,37 @@ describe("groupByRepository", () => { expect(groups[0]?.name).toBe("Other"); }); + it("routes image-builder tasks to a pinned 'Custom images' group above 'Other'", () => { + const tasks: TestTask[] = [ + { id: "t1", repository: null, originProduct: "image_builder" }, + { + id: "t2", + repository: { + fullPath: "posthog/code", + name: "code", + organization: "PostHog", + }, + originProduct: "image_builder", + }, + task("t3"), + task("t4", { + fullPath: "posthog/posthog", + name: "posthog", + organization: "PostHog", + }), + ]; + + const groups = groupByRepository(tasks, []); + + expect(groups.map((g) => g.id)).toEqual([ + "posthog/posthog", + "custom-images", + "other", + ]); + expect(groups[1]?.name).toBe("Custom images"); + expect(groups[1]?.tasks.map((t) => t.id)).toEqual(["t1", "t2"]); + }); + it("keeps the bare name for a group without an organization when others collide", () => { const tasks: TestTask[] = [ task("t1", { diff --git a/packages/core/src/sidebar/groupTasks.ts b/packages/core/src/sidebar/groupTasks.ts index e7e8be8ffb..b060bc4443 100644 --- a/packages/core/src/sidebar/groupTasks.ts +++ b/packages/core/src/sidebar/groupTasks.ts @@ -13,8 +13,11 @@ export interface TaskRepositoryInfo { export interface GroupableTask { repository: TaskRepositoryInfo | null; + originProduct?: string; } +export const CUSTOM_IMAGES_GROUP_ID = "custom-images"; + export interface TaskGroup { id: string; name: string; @@ -69,8 +72,13 @@ export function groupByRepository( for (const task of tasks) { const repository = task.repository; - const groupId = repository?.fullPath ?? "other"; - const groupName = repository?.name ?? "Other"; + const isImageBuilder = task.originProduct === "image_builder"; + const groupId = isImageBuilder + ? CUSTOM_IMAGES_GROUP_ID + : (repository?.fullPath ?? "other"); + const groupName = isImageBuilder + ? "Custom images" + : (repository?.name ?? "Other"); let group = groupMap.get(groupId); if (!group) { @@ -106,25 +114,27 @@ export function groupByRepository( } } - // The "other" group (tasks without a resolvable repository) always sorts to - // the bottom, regardless of the alphabetical or persisted folder order. - const pinOtherLast = (a: TaskGroup, b: TaskGroup): number | null => { - const aOther = a.id === "other"; - const bOther = b.id === "other"; - if (aOther && bOther) return 0; - if (aOther) return 1; - if (bOther) return -1; - return null; + // Custom-images and "other" always sort last, in that order. + const pinnedRank = (group: TaskGroup): number => { + if (group.id === CUSTOM_IMAGES_GROUP_ID) return 1; + if (group.id === "other") return 2; + return 0; + }; + const pinSpecialLast = (a: TaskGroup, b: TaskGroup): number | null => { + const aRank = pinnedRank(a); + const bRank = pinnedRank(b); + if (aRank === 0 && bRank === 0) return null; + return aRank - bRank; }; if (folderOrder.length === 0) { return groups.sort( - (a, b) => pinOtherLast(a, b) ?? a.name.localeCompare(b.name), + (a, b) => pinSpecialLast(a, b) ?? a.name.localeCompare(b.name), ); } return groups.sort((a, b) => { - const pinned = pinOtherLast(a, b); + const pinned = pinSpecialLast(a, b); if (pinned !== null) return pinned; const aIndex = folderOrder.indexOf(a.id); const bIndex = folderOrder.indexOf(b.id); diff --git a/packages/core/src/task-detail/taskCreationApiClient.ts b/packages/core/src/task-detail/taskCreationApiClient.ts index 3ef2f5ed56..b1f23c9279 100644 --- a/packages/core/src/task-detail/taskCreationApiClient.ts +++ b/packages/core/src/task-detail/taskCreationApiClient.ts @@ -9,6 +9,7 @@ export interface CreateTaskRunClientOptions { model?: string; reasoningLevel?: string; sandboxEnvironmentId?: string; + customImageId?: string; prAuthorshipMode?: PrAuthorshipMode; runSource?: CloudRunSource; signalReportId?: string; diff --git a/packages/core/src/task-detail/taskCreationSaga.ts b/packages/core/src/task-detail/taskCreationSaga.ts index b021119f1d..0ac3288600 100644 --- a/packages/core/src/task-detail/taskCreationSaga.ts +++ b/packages/core/src/task-detail/taskCreationSaga.ts @@ -400,6 +400,7 @@ export class TaskCreationSaga extends Saga< model: input.model, reasoningLevel: input.reasoningLevel, sandboxEnvironmentId: input.sandboxEnvironmentId, + customImageId: input.customImageId, prAuthorshipMode, runSource: input.cloudRunSource ?? "manual", signalReportId: input.signalReportId, diff --git a/packages/core/src/task-detail/taskInput.ts b/packages/core/src/task-detail/taskInput.ts index dba4749919..e5ff3f607e 100644 --- a/packages/core/src/task-detail/taskInput.ts +++ b/packages/core/src/task-detail/taskInput.ts @@ -17,6 +17,7 @@ export interface PrepareTaskInputOptions { reasoningLevel?: string; environmentId?: string | null; sandboxEnvironmentId?: string; + customImageId?: string; signalReportId?: string; additionalDirectories?: string[]; channelContext?: string; @@ -52,6 +53,7 @@ export function prepareTaskInput( reasoningLevel: options.reasoningLevel, environmentId: options.environmentId ?? undefined, sandboxEnvironmentId: options.sandboxEnvironmentId, + customImageId: options.customImageId, cloudPrAuthorshipMode: options.signalReportId && isCloud ? "user" : undefined, cloudRunSource: diff --git a/packages/shared/src/domain-types.ts b/packages/shared/src/domain-types.ts index 31a72890a6..ff401a1f6a 100644 --- a/packages/shared/src/domain-types.ts +++ b/packages/shared/src/domain-types.ts @@ -145,6 +145,9 @@ export interface SandboxEnvironment { has_environment_variables: boolean; private: boolean; effective_domains: string[]; + custom_image_id: string | null; + custom_image_name: string | null; + custom_image_status: string | null; created_by?: UserBasic | null; created_at: string; updated_at: string; @@ -158,6 +161,44 @@ export interface SandboxEnvironmentInput { repositories?: string[]; environment_variables?: Record; private?: boolean; + custom_image_id?: string | null; +} + +export type SandboxCustomImageStatus = + | "draft" + | "scanning" + | "scan_failed" + | "building" + | "build_failed" + | "ready" + | "archived"; + +export interface SandboxCustomImageScanFinding { + severity: string; + detail: string; +} + +export interface SandboxCustomImage { + id: string; + name: string; + description: string; + status: SandboxCustomImageStatus; + version: number; + modal_image_name: string; + repository: string; + private: boolean; + spec: Record; + spec_yaml: string; + scan_result: { + passed?: boolean; + findings?: SandboxCustomImageScanFinding[]; + }; + error: string; + build_log: string; + builder_task_id: string | null; + created_by?: UserBasic | null; + created_at: string; + updated_at: string; } interface CloudTaskUpdateBase { diff --git a/packages/shared/src/task-creation-domain.ts b/packages/shared/src/task-creation-domain.ts index 3208609fc3..9992733b7f 100644 --- a/packages/shared/src/task-creation-domain.ts +++ b/packages/shared/src/task-creation-domain.ts @@ -34,6 +34,7 @@ export interface TaskCreationInput { reasoningLevel?: string; environmentId?: string; sandboxEnvironmentId?: string; + customImageId?: string; cloudPrAuthorshipMode?: PrAuthorshipMode; cloudRunSource?: CloudRunSource; signalReportId?: string; diff --git a/packages/ui/src/features/git-interaction/components/CloudGitInteractionHeader.tsx b/packages/ui/src/features/git-interaction/components/CloudGitInteractionHeader.tsx index 8c252dc00d..bab4553982 100644 --- a/packages/ui/src/features/git-interaction/components/CloudGitInteractionHeader.tsx +++ b/packages/ui/src/features/git-interaction/components/CloudGitInteractionHeader.tsx @@ -107,6 +107,7 @@ export function CloudGitInteractionHeader({ }; if (!cloudHandoffEnabled) return null; + if (task.origin_product === "image_builder") return null; const inProgress = session?.handoffInProgress ?? false; diff --git a/packages/ui/src/features/integrations/useCloudRepoPicker.ts b/packages/ui/src/features/integrations/useCloudRepoPicker.ts new file mode 100644 index 0000000000..137fadfeea --- /dev/null +++ b/packages/ui/src/features/integrations/useCloudRepoPicker.ts @@ -0,0 +1,52 @@ +import { + useUserGithubRepositories, + useUserRepositoryIntegration, +} from "@posthog/ui/features/integrations/useIntegrations"; +import { toast } from "@posthog/ui/primitives/toast"; +import { useCallback, useState } from "react"; + +export function useCloudRepoPicker() { + const { + repositories, + isLoadingRepos, + isRefreshingRepos, + refreshRepositories, + } = useUserRepositoryIntegration(); + const [isOpen, setIsOpen] = useState(false); + const [searchQuery, setSearchQuery] = useState(""); + const { + repositories: visibleCloudRepositories, + isPending: cloudRepositoriesLoading, + hasMore, + loadMore, + } = useUserGithubRepositories(searchQuery, isOpen); + + const handleOpenChange = useCallback((open: boolean) => { + setIsOpen(open); + if (!open) { + setSearchQuery(""); + } + }, []); + + const handleRefresh = useCallback(() => { + void refreshRepositories().catch((error) => { + toast.error("Failed to refresh repositories", { + description: + error instanceof Error ? error.message : "Please try again.", + }); + }); + }, [refreshRepositories]); + + return { + repositories: isOpen ? visibleCloudRepositories : repositories, + isLoading: isLoadingRepos || (isOpen && cloudRepositoriesLoading), + isRefreshing: isRefreshingRepos, + onRefresh: handleRefresh, + open: isOpen, + onOpenChange: handleOpenChange, + searchQuery, + onSearchQueryChange: setSearchQuery, + hasMore, + onLoadMore: loadMore, + }; +} diff --git a/packages/ui/src/features/sessions/components/ImageBuilderBuildButton.tsx b/packages/ui/src/features/sessions/components/ImageBuilderBuildButton.tsx new file mode 100644 index 0000000000..d5e014c08b --- /dev/null +++ b/packages/ui/src/features/sessions/components/ImageBuilderBuildButton.tsx @@ -0,0 +1,44 @@ +import { imageFailureDetail } from "@posthog/ui/features/settings/sections/environments/imageBuildWatcher"; +import { useSandboxCustomImages } from "@posthog/ui/features/settings/sections/environments/useSandboxCustomImages"; +import { Button, Flex, Text } from "@radix-ui/themes"; + +export function ImageBuilderBuildButton({ taskId }: { taskId: string }) { + const { images, buildMutation } = useSandboxCustomImages(); + const image = images.find((img) => img.builder_task_id === taskId); + if (!image) return null; + + const inProgress = image.status === "scanning" || image.status === "building"; + const isFailed = + image.status === "scan_failed" || image.status === "build_failed"; + + return ( + + {inProgress ? ( + + {image.status === "scanning" ? "scanning…" : "building…"} + + ) : image.status === "ready" ? ( + + ready · v{image.version} + + ) : isFailed ? ( + + {image.status === "scan_failed" ? "scan failed" : "build failed"} + + ) : null} + + + ); +} diff --git a/packages/ui/src/features/sessions/components/SessionFooter.tsx b/packages/ui/src/features/sessions/components/SessionFooter.tsx index e0dc021563..82548518d7 100644 --- a/packages/ui/src/features/sessions/components/SessionFooter.tsx +++ b/packages/ui/src/features/sessions/components/SessionFooter.tsx @@ -8,6 +8,7 @@ import { import type { ContextUsage } from "@posthog/ui/features/sessions/hooks/useContextUsage"; import { Box, Flex, Text } from "@radix-ui/themes"; import { DiffStatsChip } from "./DiffStatsChip"; +import { ImageBuilderBuildButton } from "./ImageBuilderBuildButton"; import { SlotMachineLever } from "./SlotMachineLever"; interface SessionFooterProps { @@ -41,6 +42,9 @@ export function SessionFooter({ }: SessionFooterProps) { const rightSide = ( + {task?.origin_product === "image_builder" && ( + + )} {task && } diff --git a/packages/ui/src/features/settings/sections/environments/CloudEnvironmentsSettings.tsx b/packages/ui/src/features/settings/sections/environments/CloudEnvironmentsSettings.tsx index f7d6e1028b..920fc17c41 100644 --- a/packages/ui/src/features/settings/sections/environments/CloudEnvironmentsSettings.tsx +++ b/packages/ui/src/features/settings/sections/environments/CloudEnvironmentsSettings.tsx @@ -1,4 +1,11 @@ -import { ArrowLeft, PencilSimple, Plus, Trash } from "@phosphor-icons/react"; +import { + ArrowLeft, + Lock, + PencilSimple, + Plus, + Trash, + X, +} from "@phosphor-icons/react"; import { buildSandboxEnvironmentInput, emptyForm, @@ -9,21 +16,40 @@ import { } from "@posthog/core/settings/sandboxEnvironmentForm"; import type { NetworkAccessLevel, + SandboxCustomImage, + SandboxCustomImageStatus, SandboxEnvironment, } from "@posthog/shared/domain-types"; +import { useHandleOpenTask } from "@posthog/ui/features/deep-links/useHandleOpenTask"; +import { GitHubRepoPicker } from "@posthog/ui/features/folder-picker/GitHubRepoPicker"; +import { useCloudRepoPicker } from "@posthog/ui/features/integrations/useCloudRepoPicker"; import { useSettingsPageStore } from "@posthog/ui/features/settings/stores/settingsPageStore"; import { ChevronDownIcon } from "@radix-ui/react-icons"; import { + AlertDialog, Badge, Button, Checkbox, Flex, + IconButton, Text, TextArea, TextField, } from "@radix-ui/themes"; -import { useCallback, useEffect, useMemo, useState } from "react"; +import { + type ComponentProps, + useCallback, + useEffect, + useMemo, + useRef, + useState, +} from "react"; import { toast } from "../../../../primitives/toast"; +import { imageFailureDetail } from "./imageBuildWatcher"; +import { + useSandboxCustomImageDetail, + useSandboxCustomImages, +} from "./useSandboxCustomImages"; import { useSandboxEnvironments } from "./useSandboxEnvironments"; const NETWORK_ACCESS_OPTIONS: { @@ -110,6 +136,208 @@ function NetworkAccessSelect({ ); } +interface ImageFormState { + name: string; + description: string; + repository: string | null; + private: boolean; +} + +const IMAGE_STATUS_COLORS: Record< + SandboxCustomImageStatus, + ComponentProps["color"] +> = { + draft: "gray", + scanning: "blue", + building: "blue", + scan_failed: "red", + build_failed: "red", + ready: "green", + archived: "gray", +}; + +function imageLabel(image: SandboxCustomImage): string { + return `${image.name}${image.status !== "ready" ? ` (${image.status})` : ""}`; +} + +function BuildLogPane({ + image, + onClose, +}: { + image: SandboxCustomImage; + onClose: () => void; +}) { + const { data, isLoading } = useSandboxCustomImageDetail(image.id); + const scrollRef = useRef(null); + const stickToBottomRef = useRef(true); + const buildLog = data?.build_log ?? ""; + const status = data?.status ?? image.status; + const isScanning = status === "scanning"; + const isBuilding = status === "building"; + + // biome-ignore lint/correctness/useExhaustiveDependencies: re-run as the log grows to keep the scroll pinned + useEffect(() => { + const el = scrollRef.current; + if (el && stickToBottomRef.current) { + el.scrollTop = el.scrollHeight; + } + }, [buildLog]); + + const handleScroll = useCallback(() => { + const el = scrollRef.current; + if (!el) return; + stickToBottomRef.current = + el.scrollHeight - el.scrollTop - el.clientHeight < 40; + }, []); + + if (isLoading) { + return ( + + Loading build log... + + ); + } + + if (!buildLog) { + return ( + + {isScanning + ? "Security scan in progress — build output will stream once the build starts." + : isBuilding + ? "Waiting for build output…" + : "No build log yet."} + + ); + } + + return ( + + + + + Build log + + {isBuilding && ( + + + + building — streaming + + + )} + + + + + +
+
+          {buildLog}
+        
+
+
+ ); +} + +function BaseImageSelect({ + value, + images, + onChange, +}: { + value: string | null; + images: SandboxCustomImage[]; + onChange: (v: string | null) => void; +}) { + const [open, setOpen] = useState(false); + const containerRef = useRef(null); + const current = images.find((img) => img.id === value); + + useEffect(() => { + if (!open) return; + const handlePointerDown = (event: MouseEvent) => { + if (!containerRef.current?.contains(event.target as Node)) { + setOpen(false); + } + }; + document.addEventListener("mousedown", handlePointerDown); + return () => document.removeEventListener("mousedown", handlePointerDown); + }, [open]); + + return ( +
+ + {open && ( +
+ + {images.map((img) => { + const notReady = img.status !== "ready"; + return ( + + ); + })} +
+ )} +
+ ); +} + export function CloudEnvironmentsSettings() { const { environments, @@ -118,6 +346,17 @@ export function CloudEnvironmentsSettings() { updateMutation, deleteMutation, } = useSandboxEnvironments(); + const { + images, + customImagesEnabled, + customImagesDisabled, + createMutation: createImageMutation, + buildMutation, + builderTaskMutation, + deleteMutation: deleteImageMutation, + } = useSandboxCustomImages(); + const handleOpenTask = useHandleOpenTask(); + const repoPickerProps = useCloudRepoPicker(); const consumeInitialAction = useSettingsPageStore( (s) => s.consumeInitialAction, ); @@ -125,6 +364,16 @@ export function CloudEnvironmentsSettings() { const [editingEnv, setEditingEnv] = useState(null); const [isCreating, setIsCreating] = useState(false); const [form, setForm] = useState(emptyForm()); + const [imageForm, setImageForm] = useState(null); + const [editingSpecImageId, setEditingSpecImageId] = useState( + null, + ); + const [specDraft, setSpecDraft] = useState(""); + const [deleteConfirmImage, setDeleteConfirmImage] = + useState(null); + const [viewingLogImageId, setViewingLogImageId] = useState( + null, + ); useEffect(() => { const action = consumeInitialAction(); @@ -184,6 +433,9 @@ export function CloudEnvironmentsSettings() { domainValidation.domains, envVarValidation.vars, ); + if (customImagesDisabled) { + delete payload.custom_image_id; + } if (editingEnv) { await updateMutation.mutateAsync({ id: editingEnv.id, ...payload }); @@ -197,6 +449,7 @@ export function CloudEnvironmentsSettings() { hasValidationErrors, domainValidation, envVarValidation, + customImagesDisabled, createMutation, updateMutation, closeForm, @@ -208,6 +461,59 @@ export function CloudEnvironmentsSettings() { closeForm(); }, [editingEnv, deleteMutation, closeForm]); + const openCreateImage = useCallback(() => { + setImageForm({ + name: "", + description: "", + repository: null, + private: false, + }); + }, []); + + const patchImageForm = useCallback((patch: Partial) => { + setImageForm((current) => (current ? { ...current, ...patch } : current)); + }, []); + + const handleCreateImage = useCallback(async () => { + if (!imageForm) return; + const image = await createImageMutation.mutateAsync({ + name: imageForm.name.trim(), + ...(imageForm.description.trim() + ? { description: imageForm.description.trim() } + : {}), + ...(imageForm.repository ? { repository: imageForm.repository } : {}), + ...(imageForm.private ? { private: true } : {}), + }); + setImageForm(null); + if (image.builder_task_id) { + void handleOpenTask(image.builder_task_id); + } + }, [imageForm, createImageMutation, handleOpenTask]); + + const handleOpenBuilder = useCallback( + async (image: SandboxCustomImage) => { + const updated = await builderTaskMutation.mutateAsync(image.id); + if (updated.builder_task_id) { + void handleOpenTask(updated.builder_task_id); + } + }, + [builderTaskMutation, handleOpenTask], + ); + + const handleSaveSpec = useCallback( + async (image: SandboxCustomImage) => { + await buildMutation.mutateAsync({ id: image.id, specYaml: specDraft }); + setEditingSpecImageId(null); + }, + [buildMutation, specDraft], + ); + + const confirmDeleteImage = useCallback(() => { + if (!deleteConfirmImage) return; + deleteImageMutation.mutate(deleteConfirmImage.id); + setDeleteConfirmImage(null); + }, [deleteConfirmImage, deleteImageMutation]); + if (isFormOpen) { return ( @@ -246,6 +552,25 @@ export function CloudEnvironmentsSettings() { /> + {customImagesEnabled && ( + + Base image + + The sandbox image sessions in this environment start from.{" "} + + Default + {" "} + is the standard image; custom images must finish building before + they can be selected. + + setForm((f) => ({ ...f, custom_image_id: v }))} + /> + + )} + Network access @@ -486,6 +811,13 @@ export function CloudEnvironmentsSettings() { {env.allowed_domains.length !== 1 ? "s" : ""} )} + {customImagesEnabled && + env.custom_image_id && + env.custom_image_name && ( + + image: {env.custom_image_name} + + )} + + + + A custom image is a sandbox base image with your own tools and + dependencies pre-installed. Creating one starts a builder session + where you describe what the image should include; once built, pick + it as the base image of an environment. + + + {imageForm && ( + + + Name + patchImageForm({ name: e.target.value })} + placeholder="e.g. Playwright + Node 22" + /> + + + Description +