From 80bc51c40e819b427efd406bf19453b218f4504c Mon Sep 17 00:00:00 2001 From: Bryan Helmkamp Date: Wed, 9 Sep 2026 15:17:32 -0600 Subject: [PATCH] Open sandbox provider identity to plugin kinds SandboxProviderKind is now a validated string newtype instead of a closed enum. The bundled kinds (local, docker, daytona) keep their constants and a BundledProvider enum for the code paths that still dispatch on them; any other well-formed sandbox-driver kind name is accepted and names a plugin executable. EnvironmentProvider is gone: environment settings carry SandboxProviderKind directly, and is_clone_based is replaced by a workspace policy where local runs in a designated directory and every other provider clones. Server sandbox policy is keyed by kind. [server.sandbox.providers.] accepts the bundled kinds with `enabled` and any plugin kind with its launch settings (path, sha256, dev, args, env, inherit_env); bundled kinds reject the plugin keys and a kind with no entry is disabled. The OpenAPI schema, generated Rust and TypeScript clients, web settings pages, and docs follow. The environments table drops its provider CHECK enumeration in favour of the kind name rules so a plugin environment can be stored. Bundled-only code paths (run start, preflight, reconnect, terminal, details) now fail with an explicit message for a plugin kind until the driver construction function lands in the next step. Co-Authored-By: Claude Fable 5.1 --- .../app/components/environment-form.tsx | 20 +- .../app/lib/environment-providers.ts | 47 ++- .../app/routes/settings-environments.tsx | 6 +- .../app/routes/settings-sandboxes.tsx | 83 +++-- docs/public/administration/sandboxing.mdx | 9 +- .../administration/server-configuration.mdx | 35 ++- docs/public/api-reference/fabro-api.yaml | 66 ++-- docs/public/execution/environments.mdx | 2 +- lib/apps/fabro-cli/src/commands/run/create.rs | 9 +- .../src/commands/run/run_progress/mod.rs | 4 +- lib/apps/fabro-cli/src/commands/run/runner.rs | 16 +- lib/apps/fabro-cli/src/commands/runs/mod.rs | 2 +- lib/apps/fabro-server/src/demo/mod.rs | 8 +- lib/apps/fabro-server/src/diagnostics.rs | 4 +- lib/apps/fabro-server/src/install.rs | 9 +- lib/apps/fabro-server/src/run_manifest.rs | 120 ++++---- lib/apps/fabro-server/src/server.rs | 14 +- .../src/server/handler/automations.rs | 8 +- .../src/server/handler/environments.rs | 8 +- .../fabro-server/src/server/handler/runs.rs | 21 +- .../src/server/handler/sandbox.rs | 52 ++-- .../src/server/handler/sandboxes.rs | 26 +- lib/apps/fabro-server/src/server/tests.rs | 59 ++-- lib/apps/fabro-server/src/test_support.rs | 15 +- lib/apps/fabro-server/tests/it/api/install.rs | 16 +- .../fabro-server/tests/it/api/run_files.rs | 2 +- lib/apps/fabro-server/tests/it/api/runs.rs | 4 +- .../2026082801_environment_selectors.rs | 2 +- lib/components/fabro-dump/src/lib.rs | 4 +- lib/components/fabro-environment/src/store.rs | 25 +- .../fabro-environment/tests/store.rs | 27 +- lib/components/fabro-install/src/lib.rs | 19 +- .../fabro-sandbox/src/daytona/mod.rs | 2 +- lib/components/fabro-sandbox/src/details.rs | 32 +- lib/components/fabro-sandbox/src/docker.rs | 2 +- .../fabro-sandbox/src/from_environment.rs | 13 +- lib/components/fabro-sandbox/src/git_retry.rs | 14 +- lib/components/fabro-sandbox/src/provider.rs | 44 +-- .../fabro-sandbox/src/provider/daytona.rs | 2 +- .../fabro-sandbox/src/provider/docker.rs | 2 +- lib/components/fabro-sandbox/src/reconnect.rs | 18 +- .../fabro-sandbox/src/sandbox_spec.rs | 16 +- lib/components/fabro-sandbox/src/terminal.rs | 18 +- .../fabro-sandbox/src/test_support.rs | 2 +- lib/components/fabro-store/src/run_state.rs | 25 +- .../tests/serializable_projection.rs | 4 +- .../fabro-workflow/src/event/convert.rs | 2 +- .../fabro-workflow/src/operations/retry.rs | 2 +- .../fabro-workflow/src/operations/start.rs | 53 ++-- .../src/pipeline/execute/tests.rs | 2 +- .../fabro-workflow/tests/it/cp_integration.rs | 4 +- .../tests/it/daytona_integration.rs | 2 +- lib/foundation/fabro-api/build.rs | 5 + .../fabro-api/tests/run_sandbox_round_trip.rs | 4 +- .../tests/sandbox_details_round_trip.rs | 4 +- .../tests/sandbox_inventory_round_trip.rs | 4 +- lib/foundation/fabro-client/src/client.rs | 7 +- ...26050101_legacy_sandbox_to_environments.rs | 48 +-- lib/foundation/fabro-config/src/builders.rs | 7 +- .../fabro-config/src/layers/combine.rs | 11 +- .../fabro-config/src/layers/server.rs | 46 ++- .../fabro-config/src/resolve/environment.rs | 80 ++--- .../fabro-config/src/resolve/server.rs | 101 +++++-- .../fabro-config/src/tests/resolve_root.rs | 6 +- .../fabro-config/src/tests/resolve_run.rs | 15 +- .../fabro-config/src/tests/resolve_server.rs | 93 +++++- .../2026090901_environment_provider_kinds.sql | 56 ++++ lib/foundation/fabro-db/tests/sqlite.rs | 6 +- lib/foundation/fabro-types/src/lib.rs | 4 +- .../fabro-types/src/sandbox_details.rs | 4 +- .../fabro-types/src/sandbox_provider.rs | 284 ++++++++++++++++-- .../fabro-types/src/settings/mod.rs | 16 +- .../fabro-types/src/settings/run.rs | 51 +--- .../fabro-types/src/settings/server.rs | 103 +++++-- .../tests/sandbox_inventory_serde.rs | 6 +- .../fabro-types/tests/sandbox_model_serde.rs | 20 +- .../src/.openapi-generator/FILES | 4 +- .../fabro-api-client/src/api/runs-api.ts | 8 +- .../src/models/create-environment-request.ts | 8 +- .../src/models/delete-run-sandbox.ts | 8 +- .../src/models/environment-provider.ts | 27 -- .../src/models/environment-settings.ts | 8 +- .../src/models/environment.ts | 8 +- .../fabro-api-client/src/models/index.ts | 4 +- .../src/models/replace-environment-request.ts | 8 +- .../src/models/run-environment-settings.ts | 8 +- .../src/models/run-sandbox-instance.ts | 8 +- .../src/models/run-sandbox-plan.ts | 8 +- .../src/models/sandbox-info.ts | 8 +- .../src/models/sandbox-plugin-settings.ts | 36 +++ .../src/models/sandbox-provider-kind.ts | 27 -- .../models/sandbox-provider-lookup-error.ts | 8 +- .../server-sandbox-provider-settings.ts | 4 + .../server-sandbox-providers-settings.ts | 24 -- .../src/models/server-sandbox-settings.ts | 7 +- 95 files changed, 1390 insertions(+), 813 deletions(-) create mode 100644 lib/foundation/fabro-db/migrations/2026090901_environment_provider_kinds.sql delete mode 100644 lib/packages/fabro-api-client/src/models/environment-provider.ts create mode 100644 lib/packages/fabro-api-client/src/models/sandbox-plugin-settings.ts delete mode 100644 lib/packages/fabro-api-client/src/models/sandbox-provider-kind.ts delete mode 100644 lib/packages/fabro-api-client/src/models/server-sandbox-providers-settings.ts diff --git a/apps/fabro-web/app/components/environment-form.tsx b/apps/fabro-web/app/components/environment-form.tsx index 688f0adbf..a78a6cc10 100644 --- a/apps/fabro-web/app/components/environment-form.tsx +++ b/apps/fabro-web/app/components/environment-form.tsx @@ -3,7 +3,6 @@ import { ChevronRightIcon } from "@heroicons/react/20/solid"; import { EnvironmentApiDockerfileSourceInlineTypeEnum, EnvironmentNetworkMode, - EnvironmentProvider, } from "@qltysh/fabro-api-client"; import type { CreateEnvironmentRequest, @@ -15,6 +14,7 @@ import type { ReplaceEnvironmentRequest, } from "@qltysh/fabro-api-client"; +import { DOCKER_PROVIDER, isCloneBasedProvider } from "../lib/environment-providers"; import { Label, Panel, Row } from "./settings-panel"; import { INPUT_CLASS } from "./ui"; import { @@ -25,11 +25,15 @@ import { } from "./key-value-editor"; // Parse the `provider` query param used by the create flow into a creatable -// provider, defaulting to Docker for anything unexpected. -export function parseCreatableProvider(value: string | null): EnvironmentProvider { - return value === EnvironmentProvider.DAYTONA - ? EnvironmentProvider.DAYTONA - : EnvironmentProvider.DOCKER; +// provider, defaulting to Docker for anything that cannot back a managed +// environment. Kind names are validated server-side on create. +const PROVIDER_KIND_PATTERN = /^[a-z0-9]([a-z0-9-]{0,62}[a-z0-9])?$/; + +export function parseCreatableProvider(value: string | null): string { + if (value && PROVIDER_KIND_PATTERN.test(value) && isCloneBasedProvider(value)) { + return value; + } + return DOCKER_PROVIDER; } // Environment ids are server-managed file names: lowercase, digits, hyphens. @@ -49,7 +53,7 @@ type ImageSource = "image" | "dockerfile"; export interface EnvironmentFormValues { id: string; - provider: EnvironmentProvider; + provider: string; imageSource: ImageSource; dockerRef: string; dockerfile: string; @@ -69,7 +73,7 @@ export interface EnvironmentFormValues { export const EMPTY_ENVIRONMENT_FORM: EnvironmentFormValues = { id: "", - provider: EnvironmentProvider.DOCKER, + provider: DOCKER_PROVIDER, imageSource: "image", dockerRef: "", dockerfile: "", diff --git a/apps/fabro-web/app/lib/environment-providers.ts b/apps/fabro-web/app/lib/environment-providers.ts index 8002cf6a4..300fe54cb 100644 --- a/apps/fabro-web/app/lib/environment-providers.ts +++ b/apps/fabro-web/app/lib/environment-providers.ts @@ -1,17 +1,44 @@ -import { EnvironmentProvider, type Environment } from "@qltysh/fabro-api-client"; +import type { Environment, ServerSandboxProviderSettings } from "@qltysh/fabro-api-client"; -// Providers a managed environment can be created with. `local` is a reserved, -// in-memory environment, never a managed-environment provider, so it is never -// offered. The provider is fixed at creation time and cannot be changed. -export const CREATABLE_PROVIDERS = [ - EnvironmentProvider.DOCKER, - EnvironmentProvider.DAYTONA, -] as const; +// The providers linked into the server. Any other provider kind names a +// sandbox-driver plugin the operator configured under +// `server.sandbox.providers.`. +export const LOCAL_PROVIDER = "local"; +export const DOCKER_PROVIDER = "docker"; +export const DAYTONA_PROVIDER = "daytona"; + +export const BUNDLED_PROVIDERS = [LOCAL_PROVIDER, DOCKER_PROVIDER, DAYTONA_PROVIDER] as const; + +export type ProviderSettingsMap = { [kind: string]: ServerSandboxProviderSettings }; + +// `local` runs in the caller's directory and never clones. Every other +// provider owns an isolated workspace that Fabro clones into. +export function isCloneBasedProvider(provider: string): boolean { + return provider !== LOCAL_PROVIDER; +} // Whether a server-managed environment can back Git-targeted work such as -// automations: only the clone-based (creatable) providers qualify. +// automations: only clone-based providers qualify. export function isCloneBasedEnvironment(environment: Environment): boolean { - return (CREATABLE_PROVIDERS as readonly string[]).includes(environment.provider); + return isCloneBasedProvider(environment.provider); +} + +// Providers a managed environment can be created with: every enabled +// clone-based provider. `local` is a reserved, in-memory environment, never a +// managed-environment provider, so it is never offered. +export function creatableProviders(providers: ProviderSettingsMap): string[] { + return Object.keys(providers) + .filter((kind) => isCloneBasedProvider(kind) && providers[kind]?.enabled) + .sort(compareProviderKinds); +} + +// Bundled kinds first, in their canonical order, then plugins alphabetically. +export function compareProviderKinds(left: string, right: string): number { + const rank = (kind: string) => { + const index = (BUNDLED_PROVIDERS as readonly string[]).indexOf(kind); + return index === -1 ? BUNDLED_PROVIDERS.length : index; + }; + return rank(left) - rank(right) || left.localeCompare(right); } export function providerLabel(provider: string): string { diff --git a/apps/fabro-web/app/routes/settings-environments.tsx b/apps/fabro-web/app/routes/settings-environments.tsx index b3059ab52..fdd13aee4 100644 --- a/apps/fabro-web/app/routes/settings-environments.tsx +++ b/apps/fabro-web/app/routes/settings-environments.tsx @@ -9,7 +9,7 @@ import type { Environment } from "@qltysh/fabro-api-client"; import { ApiError, apiData, environmentsApi } from "../lib/api-client"; import { useEnvironments, useServerSettings } from "../lib/queries"; import { queryKeys } from "../lib/query-keys"; -import { CREATABLE_PROVIDERS, providerLabel } from "../lib/environment-providers"; +import { creatableProviders, providerLabel } from "../lib/environment-providers"; import { Badge, Muted, @@ -67,9 +67,7 @@ const NEW_BUTTON_CLASS = // environment's lifetime. `local` is never offered (it's reserved/in-memory). function NewEnvironmentMenu() { const { data } = useServerSettings(); - const providers = data - ? CREATABLE_PROVIDERS.filter((provider) => data.server.sandbox.providers[provider].enabled) - : []; + const providers = data ? creatableProviders(data.server.sandbox.providers) : []; if (providers.length === 0) { return ( diff --git a/apps/fabro-web/app/routes/settings-sandboxes.tsx b/apps/fabro-web/app/routes/settings-sandboxes.tsx index d51197c73..577f81866 100644 --- a/apps/fabro-web/app/routes/settings-sandboxes.tsx +++ b/apps/fabro-web/app/routes/settings-sandboxes.tsx @@ -2,7 +2,7 @@ import { useMemo, useState } from "react"; import { Link } from "react-router"; import { ChevronDownIcon } from "@heroicons/react/16/solid"; import { ComputerDesktopIcon } from "@heroicons/react/24/outline"; -import type { ServerSandboxProvidersSettings } from "@qltysh/fabro-api-client"; +import type { ServerSandboxProviderSettings } from "@qltysh/fabro-api-client"; import { useServerSettings } from "../lib/queries"; import { Dot, @@ -12,24 +12,56 @@ import { SettingsPageIntro, } from "../components/settings-panel"; import { plural } from "../lib/plural"; +import { + DAYTONA_PROVIDER, + DOCKER_PROVIDER, + LOCAL_PROVIDER, + compareProviderKinds, + providerLabel, + type ProviderSettingsMap, +} from "../lib/environment-providers"; export function meta() { return [{ title: "Sandboxes — Fabro" }]; } -type SandboxProviderId = "local" | "docker" | "daytona"; - type SandboxProvider = { - id: SandboxProviderId; + id: string; name: string; description: string; enabled: boolean; + bundled: boolean; secretName?: string; }; const DESCRIPTION = "Runtime environments where workflow stages execute. Configured via settings.toml."; +// Display copy for the providers linked into the server. Any other kind is a +// sandbox-driver plugin configured under `server.sandbox.providers.`. +const BUNDLED_PROVIDER_COPY: Record> = { + [LOCAL_PROVIDER]: { + name: "Local", + description: "Run stages directly on the Fabro host.", + }, + [DOCKER_PROVIDER]: { + name: "Docker", + description: "Run stages in isolated Docker containers on the host daemon.", + }, + [DAYTONA_PROVIDER]: { + name: "Daytona", + description: "Run stages in cloud sandboxes managed by Daytona.", + secretName: "DAYTONA_API_KEY", + }, +}; + +function pluginDescription(settings: ServerSandboxProviderSettings): string { + const path = settings.plugin?.path; + return path + ? `Sandbox plugin executable at ${path}.` + : "Sandbox plugin executable resolved from PATH."; +} + export default function SettingsSandboxes() { const query = useServerSettings(); const settings = query.data; @@ -42,29 +74,24 @@ export default function SettingsSandboxes() { ); } -function ProvidersPanel({ settings }: { settings: ServerSandboxProvidersSettings }) { +function ProvidersPanel({ settings }: { settings: ProviderSettingsMap }) { const providers: SandboxProvider[] = useMemo( - () => [ - { - id: "local", - name: "Local", - description: "Run stages directly on the Fabro host.", - enabled: settings.local.enabled, - }, - { - id: "docker", - name: "Docker", - description: "Run stages in isolated Docker containers on the host daemon.", - enabled: settings.docker.enabled, - }, - { - id: "daytona", - name: "Daytona", - description: "Run stages in cloud sandboxes managed by Daytona.", - enabled: settings.daytona.enabled, - secretName: "DAYTONA_API_KEY", - }, - ], + () => + Object.keys(settings) + .sort(compareProviderKinds) + .map((id) => { + const entry = settings[id]; + const copy = BUNDLED_PROVIDER_COPY[id]; + return copy + ? { id, enabled: entry.enabled, bundled: true, ...copy } + : { + id, + enabled: entry.enabled, + bundled: false, + name: providerLabel(id), + description: pluginDescription(entry), + }; + }), [settings], ); @@ -138,7 +165,7 @@ function ProviderLogo({ provider }: { provider: SandboxProvider }) { "grid size-10 shrink-0 place-items-center rounded-md bg-ice-50 ring-1 ring-line-strong"; const dim = provider.enabled ? "" : "opacity-60"; - if (provider.id === "local") { + if (provider.id === LOCAL_PROVIDER) { return (