diff --git a/apps/api-client-go/.openapi-generator/FILES b/apps/api-client-go/.openapi-generator/FILES index 61bd90986..d96fb1ba7 100644 --- a/apps/api-client-go/.openapi-generator/FILES +++ b/apps/api-client-go/.openapi-generator/FILES @@ -113,6 +113,7 @@ model_signed_port_preview_url.go model_ssh_access_dto.go model_ssh_access_validation_dto.go model_storage_access_dto.go +model_system_images_config.go model_system_role.go model_toolbox_proxy_url.go model_trace_span.go diff --git a/apps/api-client-go/api/openapi.yaml b/apps/api-client-go/api/openapi.yaml index 7a4f572c9..949fe6612 100644 --- a/apps/api-client-go/api/openapi.yaml +++ b/apps/api-client-go/api/openapi.yaml @@ -5112,6 +5112,22 @@ components: - clientId - issuer type: object + SystemImagesConfig: + properties: + base: + description: Curated Base image reference + type: string + python: + description: Curated Python image reference + type: string + node: + description: Curated Node.js image reference + type: string + required: + - base + - node + - python + type: object RateLimitEntry: properties: ttl: @@ -5147,6 +5163,7 @@ components: version: 0.0.1 posthog: "" oidc: "" + systemImages: "" linkedAccountsEnabled: true announcements: feature-update: @@ -5176,6 +5193,10 @@ components: allOf: - $ref: "#/components/schemas/OidcConfig" description: OIDC configuration + systemImages: + allOf: + - $ref: "#/components/schemas/SystemImagesConfig" + description: Curated system images available for Box creation linkedAccountsEnabled: description: Whether linked accounts are enabled example: true @@ -5242,6 +5263,7 @@ components: - oidc - proxyTemplateUrl - proxyToolboxUrl + - systemImages - version type: object CreateApiKey: diff --git a/apps/api-client-go/model_boxlite_configuration.go b/apps/api-client-go/model_boxlite_configuration.go index debcc5a5d..a12c84c9c 100644 --- a/apps/api-client-go/model_boxlite_configuration.go +++ b/apps/api-client-go/model_boxlite_configuration.go @@ -27,6 +27,8 @@ type BoxliteConfiguration struct { Posthog *PosthogConfig `json:"posthog,omitempty"` // OIDC configuration Oidc OidcConfig `json:"oidc"` + // Curated system images available for Box creation + SystemImages SystemImagesConfig `json:"systemImages"` // Whether linked accounts are enabled LinkedAccountsEnabled bool `json:"linkedAccountsEnabled"` // System announcements @@ -62,10 +64,11 @@ type _BoxliteConfiguration BoxliteConfiguration // This constructor will assign default values to properties that have it defined, // and makes sure properties required by API are set, but the set of arguments // will change when the set of required properties is changed -func NewBoxliteConfiguration(version string, oidc OidcConfig, linkedAccountsEnabled bool, announcements map[string]Announcement, proxyTemplateUrl string, proxyToolboxUrl string, dashboardUrl string, maintananceMode bool, environment string) *BoxliteConfiguration { +func NewBoxliteConfiguration(version string, oidc OidcConfig, systemImages SystemImagesConfig, linkedAccountsEnabled bool, announcements map[string]Announcement, proxyTemplateUrl string, proxyToolboxUrl string, dashboardUrl string, maintananceMode bool, environment string) *BoxliteConfiguration { this := BoxliteConfiguration{} this.Version = version this.Oidc = oidc + this.SystemImages = systemImages this.LinkedAccountsEnabled = linkedAccountsEnabled this.Announcements = announcements this.ProxyTemplateUrl = proxyTemplateUrl @@ -164,6 +167,30 @@ func (o *BoxliteConfiguration) SetOidc(v OidcConfig) { o.Oidc = v } +// GetSystemImages returns the SystemImages field value +func (o *BoxliteConfiguration) GetSystemImages() SystemImagesConfig { + if o == nil { + var ret SystemImagesConfig + return ret + } + + return o.SystemImages +} + +// GetSystemImagesOk returns a tuple with the SystemImages field value +// and a boolean to check if the value has been set. +func (o *BoxliteConfiguration) GetSystemImagesOk() (*SystemImagesConfig, bool) { + if o == nil { + return nil, false + } + return &o.SystemImages, true +} + +// SetSystemImages sets field value +func (o *BoxliteConfiguration) SetSystemImages(v SystemImagesConfig) { + o.SystemImages = v +} + // GetLinkedAccountsEnabled returns the LinkedAccountsEnabled field value func (o *BoxliteConfiguration) GetLinkedAccountsEnabled() bool { if o == nil { @@ -539,6 +566,7 @@ func (o BoxliteConfiguration) ToMap() (map[string]interface{}, error) { toSerialize["posthog"] = o.Posthog } toSerialize["oidc"] = o.Oidc + toSerialize["systemImages"] = o.SystemImages toSerialize["linkedAccountsEnabled"] = o.LinkedAccountsEnabled toSerialize["announcements"] = o.Announcements if !IsNil(o.PylonAppId) { @@ -579,6 +607,7 @@ func (o *BoxliteConfiguration) UnmarshalJSON(data []byte) (err error) { requiredProperties := []string{ "version", "oidc", + "systemImages", "linkedAccountsEnabled", "announcements", "proxyTemplateUrl", @@ -618,6 +647,7 @@ func (o *BoxliteConfiguration) UnmarshalJSON(data []byte) (err error) { delete(additionalProperties, "version") delete(additionalProperties, "posthog") delete(additionalProperties, "oidc") + delete(additionalProperties, "systemImages") delete(additionalProperties, "linkedAccountsEnabled") delete(additionalProperties, "announcements") delete(additionalProperties, "pylonAppId") diff --git a/apps/api-client-go/model_system_images_config.go b/apps/api-client-go/model_system_images_config.go new file mode 100644 index 000000000..1a85f3a5d --- /dev/null +++ b/apps/api-client-go/model_system_images_config.go @@ -0,0 +1,228 @@ +/* +BoxLite + +BoxLite AI platform API Docs + +API version: 1.0 +Contact: support@boxlite.com +*/ + +// Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT. + +package apiclient + +import ( + "encoding/json" + "fmt" +) + +// checks if the SystemImagesConfig type satisfies the MappedNullable interface at compile time +var _ MappedNullable = &SystemImagesConfig{} + +// SystemImagesConfig struct for SystemImagesConfig +type SystemImagesConfig struct { + // Curated Base image reference + Base string `json:"base"` + // Curated Python image reference + Python string `json:"python"` + // Curated Node.js image reference + Node string `json:"node"` + AdditionalProperties map[string]interface{} +} + +type _SystemImagesConfig SystemImagesConfig + +// NewSystemImagesConfig instantiates a new SystemImagesConfig object +// This constructor will assign default values to properties that have it defined, +// and makes sure properties required by API are set, but the set of arguments +// will change when the set of required properties is changed +func NewSystemImagesConfig(base string, python string, node string) *SystemImagesConfig { + this := SystemImagesConfig{} + this.Base = base + this.Python = python + this.Node = node + return &this +} + +// NewSystemImagesConfigWithDefaults instantiates a new SystemImagesConfig object +// This constructor will only assign default values to properties that have it defined, +// but it doesn't guarantee that properties required by API are set +func NewSystemImagesConfigWithDefaults() *SystemImagesConfig { + this := SystemImagesConfig{} + return &this +} + +// GetBase returns the Base field value +func (o *SystemImagesConfig) GetBase() string { + if o == nil { + var ret string + return ret + } + + return o.Base +} + +// GetBaseOk returns a tuple with the Base field value +// and a boolean to check if the value has been set. +func (o *SystemImagesConfig) GetBaseOk() (*string, bool) { + if o == nil { + return nil, false + } + return &o.Base, true +} + +// SetBase sets field value +func (o *SystemImagesConfig) SetBase(v string) { + o.Base = v +} + +// GetPython returns the Python field value +func (o *SystemImagesConfig) GetPython() string { + if o == nil { + var ret string + return ret + } + + return o.Python +} + +// GetPythonOk returns a tuple with the Python field value +// and a boolean to check if the value has been set. +func (o *SystemImagesConfig) GetPythonOk() (*string, bool) { + if o == nil { + return nil, false + } + return &o.Python, true +} + +// SetPython sets field value +func (o *SystemImagesConfig) SetPython(v string) { + o.Python = v +} + +// GetNode returns the Node field value +func (o *SystemImagesConfig) GetNode() string { + if o == nil { + var ret string + return ret + } + + return o.Node +} + +// GetNodeOk returns a tuple with the Node field value +// and a boolean to check if the value has been set. +func (o *SystemImagesConfig) GetNodeOk() (*string, bool) { + if o == nil { + return nil, false + } + return &o.Node, true +} + +// SetNode sets field value +func (o *SystemImagesConfig) SetNode(v string) { + o.Node = v +} + +func (o SystemImagesConfig) MarshalJSON() ([]byte, error) { + toSerialize,err := o.ToMap() + if err != nil { + return []byte{}, err + } + return json.Marshal(toSerialize) +} + +func (o SystemImagesConfig) ToMap() (map[string]interface{}, error) { + toSerialize := map[string]interface{}{} + toSerialize["base"] = o.Base + toSerialize["python"] = o.Python + toSerialize["node"] = o.Node + + for key, value := range o.AdditionalProperties { + toSerialize[key] = value + } + + return toSerialize, nil +} + +func (o *SystemImagesConfig) UnmarshalJSON(data []byte) (err error) { + // This validates that all required properties are included in the JSON object + // by unmarshalling the object into a generic map with string keys and checking + // that every required field exists as a key in the generic map. + requiredProperties := []string{ + "base", + "python", + "node", + } + + allProperties := make(map[string]interface{}) + + err = json.Unmarshal(data, &allProperties) + + if err != nil { + return err; + } + + for _, requiredProperty := range(requiredProperties) { + if _, exists := allProperties[requiredProperty]; !exists { + return fmt.Errorf("no value given for required property %v", requiredProperty) + } + } + + varSystemImagesConfig := _SystemImagesConfig{} + + err = json.Unmarshal(data, &varSystemImagesConfig) + + if err != nil { + return err + } + + *o = SystemImagesConfig(varSystemImagesConfig) + + additionalProperties := make(map[string]interface{}) + + if err = json.Unmarshal(data, &additionalProperties); err == nil { + delete(additionalProperties, "base") + delete(additionalProperties, "python") + delete(additionalProperties, "node") + o.AdditionalProperties = additionalProperties + } + + return err +} + +type NullableSystemImagesConfig struct { + value *SystemImagesConfig + isSet bool +} + +func (v NullableSystemImagesConfig) Get() *SystemImagesConfig { + return v.value +} + +func (v *NullableSystemImagesConfig) Set(val *SystemImagesConfig) { + v.value = val + v.isSet = true +} + +func (v NullableSystemImagesConfig) IsSet() bool { + return v.isSet +} + +func (v *NullableSystemImagesConfig) Unset() { + v.value = nil + v.isSet = false +} + +func NewNullableSystemImagesConfig(val *SystemImagesConfig) *NullableSystemImagesConfig { + return &NullableSystemImagesConfig{value: val, isSet: true} +} + +func (v NullableSystemImagesConfig) MarshalJSON() ([]byte, error) { + return json.Marshal(v.value) +} + +func (v *NullableSystemImagesConfig) UnmarshalJSON(src []byte) error { + v.isSet = true + return json.Unmarshal(src, &v.value) +} diff --git a/apps/api/src/box/constants/curated-images.constant.ts b/apps/api/src/box/constants/curated-images.constant.ts index f70a15208..3c52670e6 100644 --- a/apps/api/src/box/constants/curated-images.constant.ts +++ b/apps/api/src/box/constants/curated-images.constant.ts @@ -5,6 +5,7 @@ */ import { BadRequestError } from '../../exceptions/bad-request.exception' +import { configuredSystemImages } from '../../common/constants/system-images.constant' /** * Temporary curated-image gate: boxes may only boot from this fixed set of curated OCI @@ -17,29 +18,10 @@ import { BadRequestError } from '../../exceptions/bad-request.exception' * Env overrides (set on the Api service in apps/infra/sst.config.ts) allow ref * rotation without a code deploy; the fallbacks cover local/dev runs. */ -type SupportedImageSource = { - envVar: string - fallbackRef: string -} - -const SUPPORTED_IMAGE_SOURCES: SupportedImageSource[] = [ - { - envVar: 'BOXLITE_SYSTEM_BASE_IMAGE', - fallbackRef: 'ghcr.io/boxlite-ai/boxlite-agent-base:20260605-p0-r3', - }, - { - envVar: 'BOXLITE_SYSTEM_PYTHON_IMAGE', - fallbackRef: 'ghcr.io/boxlite-ai/boxlite-agent-python:20260605-p0-r3', - }, - { - envVar: 'BOXLITE_SYSTEM_NODE_IMAGE', - fallbackRef: 'ghcr.io/boxlite-ai/boxlite-agent-node:20260605-p0-r3', - }, -] - /** Curated OCI refs a box may boot from. The first entry is the default image. */ export function supportedImages(): string[] { - return SUPPORTED_IMAGE_SOURCES.map(({ envVar, fallbackRef }) => process.env[envVar] || fallbackRef) + const images = configuredSystemImages() + return [images.base, images.python, images.node] } /** diff --git a/apps/api/src/common/constants/system-images.constant.ts b/apps/api/src/common/constants/system-images.constant.ts new file mode 100644 index 000000000..3f5811be6 --- /dev/null +++ b/apps/api/src/common/constants/system-images.constant.ts @@ -0,0 +1,19 @@ +export const DEFAULT_SYSTEM_IMAGES = { + base: 'ghcr.io/boxlite-ai/boxlite-agent-base:20260605-p0-r3', + python: 'ghcr.io/boxlite-ai/boxlite-agent-python:20260605-p0-r3', + node: 'ghcr.io/boxlite-ai/boxlite-agent-node:20260605-p0-r3', +} as const + +export type SystemImages = { + base: string + python: string + node: string +} + +export function configuredSystemImages(env: NodeJS.ProcessEnv = process.env): SystemImages { + return { + base: env.BOXLITE_SYSTEM_BASE_IMAGE || DEFAULT_SYSTEM_IMAGES.base, + python: env.BOXLITE_SYSTEM_PYTHON_IMAGE || DEFAULT_SYSTEM_IMAGES.python, + node: env.BOXLITE_SYSTEM_NODE_IMAGE || DEFAULT_SYSTEM_IMAGES.node, + } +} diff --git a/apps/api/src/config/configuration.ts b/apps/api/src/config/configuration.ts index 3b2f733fd..86a0dde16 100644 --- a/apps/api/src/config/configuration.ts +++ b/apps/api/src/config/configuration.ts @@ -4,6 +4,8 @@ * SPDX-License-Identifier: AGPL-3.0 */ +import { configuredSystemImages } from '../common/constants/system-images.constant' + function csvEnv(value?: string): string[] { return (value ?? '') .split(',') @@ -73,6 +75,7 @@ const configuration = { dashboardUrl: process.env.DASHBOARD_URL, // Default to empty string - dashboard will then hit '/api' dashboardBaseApiUrl: process.env.DASHBOARD_BASE_API_URL || '', + systemImages: configuredSystemImages(), // Currently unconsumed (Daytona-port residue): nothing reads `systemSourceRegistry`. // Box images are a fixed curated set of digest-pinned ghcr.io refs pulled directly by // the runner (see box/constants/curated-images.constant.ts), not mirrored from a source diff --git a/apps/api/src/config/dto/configuration.dto.ts b/apps/api/src/config/dto/configuration.dto.ts index 126d031e3..32678c3cf 100644 --- a/apps/api/src/config/dto/configuration.dto.ts +++ b/apps/api/src/config/dto/configuration.dto.ts @@ -9,6 +9,21 @@ import { IsBoolean, IsNumber, IsOptional, IsString } from 'class-validator' import { TypedConfigService } from '../typed-config.service' import { EndSessionState, isValidHttpUrl } from '../oidc-metadata.service' +@ApiSchema({ name: 'SystemImagesConfig' }) +export class SystemImagesConfig { + @ApiProperty({ description: 'Curated Base image reference' }) + @IsString() + base: string + + @ApiProperty({ description: 'Curated Python image reference' }) + @IsString() + python: string + + @ApiProperty({ description: 'Curated Node.js image reference' }) + @IsString() + node: string +} + @ApiSchema({ name: 'Announcement' }) export class Announcement { @ApiProperty({ @@ -149,6 +164,12 @@ export class ConfigurationDto { }) oidc: OidcConfig + @ApiProperty({ + description: 'Curated system images available for Box creation', + type: SystemImagesConfig, + }) + systemImages: SystemImagesConfig + @ApiProperty({ description: 'Whether linked accounts are enabled', example: true, @@ -264,6 +285,7 @@ export class ConfigurationDto { ? validatedEndSessionEndpoint(configService.get('oidc.endSessionEndpoint')) : undefined, } + this.systemImages = configService.getOrThrow('systemImages') this.linkedAccountsEnabled = configService.get('oidc.managementApi.enabled') this.proxyTemplateUrl = configService.getOrThrow('proxy.templateUrl') this.proxyToolboxUrl = configService.getOrThrow('proxy.toolboxUrl') diff --git a/apps/dashboard/src/components/Box/CreateBoxDialog.test.tsx b/apps/dashboard/src/components/Box/CreateBoxDialog.test.tsx index ae951fcdf..e5604ab0d 100644 --- a/apps/dashboard/src/components/Box/CreateBoxDialog.test.tsx +++ b/apps/dashboard/src/components/Box/CreateBoxDialog.test.tsx @@ -10,13 +10,24 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { CreateBoxDialog, resolvePerBoxLimits } from './CreateBoxDialog' // Mutable org returned by the mocked hook; each test sets `state.org`. -const state = vi.hoisted(() => ({ org: null as unknown })) +const state = vi.hoisted(() => ({ + org: null as unknown, + systemImages: { + base: 'ghcr.io/boxlite-ai/boxlite-agent-base:test-base', + python: 'ghcr.io/boxlite-ai/boxlite-agent-python:test-python', + node: 'ghcr.io/boxlite-ai/boxlite-agent-node:test-node', + }, + mutateAsync: vi.fn(), +})) vi.mock('@/hooks/useSelectedOrganization', () => ({ useSelectedOrganization: () => ({ selectedOrganization: state.org }), })) +vi.mock('@/hooks/useConfig', () => ({ + useConfig: () => ({ systemImages: state.systemImages }), +})) vi.mock('@/hooks/mutations/useCreateBoxMutation', () => ({ - useCreateBoxMutation: () => ({ mutateAsync: vi.fn() }), + useCreateBoxMutation: () => ({ mutateAsync: state.mutateAsync }), })) vi.mock('sonner', () => ({ toast: { error: vi.fn(), success: vi.fn() } })) vi.mock('react-router-dom', async (importOriginal) => { @@ -65,6 +76,7 @@ describe('CreateBoxDialog per-org resource cap', () => { beforeEach(() => { globalThis.IS_REACT_ACT_ENVIRONMENT = true state.org = makeOrg({ maxCpuPerBox: 4, maxMemoryPerBox: 8, maxDiskPerBox: 10 }) + state.mutateAsync.mockResolvedValue({ id: 'box-1' }) }) afterEach(() => { @@ -100,6 +112,17 @@ describe('CreateBoxDialog per-org resource cap', () => { return document.querySelector('input[placeholder="my-new-box"]') } + it('uses the API-provided system image refs and defaults to the API Base image', async () => { + await renderOpen() + + expect(document.body.textContent).toContain('Base') + const createButton = [...document.querySelectorAll('button')].find((button) => button.textContent?.includes('Create Box')) + await act(async () => createButton?.dispatchEvent(new MouseEvent('click', { bubbles: true }))) + await flush() + + expect(state.mutateAsync).toHaveBeenCalledWith(expect.objectContaining({ image: state.systemImages.base })) + }) + it('clamps an over-max CPU input to the org maximum and shows a red contact-support hint', async () => { await renderOpen() const input = cpuInput() diff --git a/apps/dashboard/src/components/Box/CreateBoxDialog.tsx b/apps/dashboard/src/components/Box/CreateBoxDialog.tsx index c0953b140..6b17bad21 100644 --- a/apps/dashboard/src/components/Box/CreateBoxDialog.tsx +++ b/apps/dashboard/src/components/Box/CreateBoxDialog.tsx @@ -7,6 +7,7 @@ import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from '@/components/ui/dropdown-menu' import { RoutePath } from '@/enums/RoutePath' import { useCreateBoxMutation } from '@/hooks/mutations/useCreateBoxMutation' +import { useConfig } from '@/hooks/useConfig' import { useSelectedOrganization } from '@/hooks/useSelectedOrganization' import { getBoxRouteId } from '@/lib/box-identity' import { handleApiError } from '@/lib/error-handling' @@ -19,12 +20,6 @@ import { toast } from 'sonner' const NAME_REGEX = /^[a-zA-Z0-9][a-zA-Z0-9._-]*$/ -const SUPPORTED_BOX_IMAGES = [ - { id: 'base', name: 'Base', ref: 'ghcr.io/boxlite-ai/boxlite-agent-base:20260605-p0-r3', isDefault: true }, - { id: 'python', name: 'Python', ref: 'ghcr.io/boxlite-ai/boxlite-agent-python:20260605-p0-r3', isDefault: false }, - { id: 'node', name: 'Node.js', ref: 'ghcr.io/boxlite-ai/boxlite-agent-node:20260605-p0-r3', isDefault: false }, -] as const - const DEFAULTS = { cpu: 1, memory: 1, disk: 10 } const SUPPORT_EMAIL = 'support@boxlite.ai' @@ -205,8 +200,14 @@ export const CreateBoxDialog = ({ const setOpen = onOpenChange ?? setInternalOpen const { selectedOrganization } = useSelectedOrganization() + const { systemImages } = useConfig() const createBoxMutation = useCreateBoxMutation() - const defaultImage = SUPPORTED_BOX_IMAGES.find((i) => i.isDefault) ?? SUPPORTED_BOX_IMAGES[0] + const supportedBoxImages = [ + { id: 'base', name: 'Base', ref: systemImages.base, isDefault: true }, + { id: 'python', name: 'Python', ref: systemImages.python, isDefault: false }, + { id: 'node', name: 'Node.js', ref: systemImages.node, isDefault: false }, + ] as const + const defaultImage = supportedBoxImages[0] // Per-box ceilings for the current org (backend rejects a create above these). const limits = resolvePerBoxLimits(selectedOrganization) @@ -267,7 +268,7 @@ export const CreateBoxDialog = ({ })) }, [open, cpu, memory, disk, limits.cpu, limits.memory, limits.disk]) - const selectedImage = SUPPORTED_BOX_IMAGES.find((i) => i.ref === imageRef) ?? defaultImage + const selectedImage = supportedBoxImages.find((i) => i.ref === imageRef) ?? defaultImage const nameValid = !name || NAME_REGEX.test(name) const handleCreate = async () => { @@ -352,7 +353,7 @@ export const CreateBoxDialog = ({ align="start" className="min-w-[var(--radix-dropdown-menu-trigger-width)] font-mono text-[12px]" > - {SUPPORTED_BOX_IMAGES.map((img) => ( + {supportedBoxImages.map((img) => (