From 6af653c44f87160aec7786ce46dbaf7836d05633 Mon Sep 17 00:00:00 2001 From: tester Date: Mon, 27 Jul 2026 00:39:01 +0800 Subject: [PATCH 01/10] feat: support custom container capabilities --- .github/workflows/test.yml | 6 +- apps/api-client-go/.openapi-generator/FILES | 2 + apps/api-client-go/api/openapi.yaml | 57 +++ .../model_linux_capabilities_test.go | 71 ++++ apps/api-client-go/model_box.go | 41 +- .../model_box_advanced_options.go | 123 ++++++ apps/api-client-go/model_job_type.go | 5 +- .../api-client-go/model_linux_capabilities.go | 150 +++++++ .../api-client-go/model_runner_healthcheck.go | 43 +- .../model_runner_healthcheck_test.go | 71 ++++ .../src/box/common/box-advanced-options.ts | 28 ++ .../src/box/constants/runner-features.spec.ts | 31 ++ apps/api/src/box/constants/runner-features.ts | 22 + .../src/box/controllers/runner.controller.ts | 1 + .../src/box/dto/box-advanced-options.dto.ts | 30 ++ apps/api/src/box/dto/box.dto.spec.ts | 3 + apps/api/src/box/dto/box.dto.ts | 10 + apps/api/src/box/dto/create-box.dto.ts | 59 ++- apps/api/src/box/dto/job-type-map.dto.ts | 6 + apps/api/src/box/dto/runner-health.dto.ts | 10 + apps/api/src/box/entities/box.entity.ts | 7 + apps/api/src/box/entities/runner.entity.ts | 6 + apps/api/src/box/enums/job-type.enum.ts | 2 + .../box-actions/box-start.action.spec.ts | 109 ++++- .../managers/box-actions/box-start.action.ts | 26 +- .../src/box/runner-adapter/runnerAdapter.ts | 1 + .../runner-adapter/runnerAdapter.v0.spec.ts | 87 ++++ .../box/runner-adapter/runnerAdapter.v0.ts | 21 +- .../runner-adapter/runnerAdapter.v2.spec.ts | 99 +++++ .../box/runner-adapter/runnerAdapter.v2.ts | 20 +- apps/api/src/box/services/box.service.spec.ts | 58 ++- apps/api/src/box/services/box.service.ts | 23 +- .../box/services/job-state-handler.service.ts | 2 + apps/api/src/box/services/runner.service.ts | 27 +- .../box/utils/capability-validation.util.ts | 35 ++ .../boxlite-rest/boxlite-box.controller.ts | 68 ++- .../boxlite-config.controller.spec.ts | 15 + .../boxlite-rest/boxlite-config.controller.ts | 1 + .../boxlite-rest/boxlite-rest-routing.spec.ts | 107 +++++ .../src/boxlite-rest/dto/box-response.dto.ts | 8 + .../boxlite-rest/dto/create-box.dto.spec.ts | 78 +++- .../src/boxlite-rest/dto/create-box.dto.ts | 39 ++ .../mappers/box-to-box.mapper.spec.ts | 48 +++ .../boxlite-rest/mappers/box-to-box.mapper.ts | 17 + ...000-add-box-capabilities-migration.spec.ts | 40 ++ ...00000000-add-box-capabilities-migration.ts | 30 ++ apps/dashboard/src/mocks/fixtures.ts | 1 + apps/hack/go-client/postprocess.sh | 13 + apps/libs/api-client/src/docs/Box.md | 2 + .../api-client/src/docs/BoxAdvancedOptions.md | 9 + apps/libs/api-client/src/docs/JobType.md | 4 + .../api-client/src/docs/LinuxCapabilities.md | 10 + .../src/models/box-advanced-options.ts | 15 + apps/libs/api-client/src/models/box.ts | 262 ++++++------ apps/libs/api-client/src/models/index.ts | 2 + apps/libs/api-client/src/models/job-type.ts | 4 +- .../src/models/linux-capabilities.ts | 16 + .../src/models/runner-healthcheck.ts | 58 +-- .../src/.openapi-generator/FILES | 8 + .../libs/runner-api-client/src/api/box-api.ts | 153 +++++++ .../src/docs/AdvancedBoxOptionsDTO.md | 9 + .../libs/runner-api-client/src/docs/BoxApi.md | 119 ++++++ .../src/docs/ContainerCapabilitiesDTO.md | 10 + .../src/docs/CreateBoxWithCapabilitiesDTO.md | 60 +++ .../src/docs/RecoverBoxWithCapabilitiesDTO.md | 42 ++ .../src/docs/RunnerInfoResponseDTO.md | 2 + .../src/models/advanced-box-options-dto.ts | 15 + .../src/models/container-capabilities-dto.ts | 14 + .../create-box-with-capabilities-dto.ts | 51 +++ .../runner-api-client/src/models/index.ts | 4 + .../recover-box-with-capabilities-dto.ts | 36 ++ .../src/models/runner-info-response-dto.ts | 1 + apps/runner/internal/features.go | 8 + apps/runner/pkg/api/controllers/box.go | 158 ++++++- .../api/controllers/box_capabilities_test.go | 231 ++++++++++ apps/runner/pkg/api/controllers/info.go | 1 + apps/runner/pkg/api/docs/docs.go | 319 ++++++++++++++ apps/runner/pkg/api/docs/swagger.json | 298 +++++++++++++ apps/runner/pkg/api/docs/swagger.yaml | 217 ++++++++++ apps/runner/pkg/api/dto/box.go | 118 ++++++ .../pkg/api/dto/box_capabilities_test.go | 113 +++++ apps/runner/pkg/api/dto/info.go | 1 + apps/runner/pkg/api/server.go | 2 + apps/runner/pkg/boxlite/client.go | 15 + apps/runner/pkg/boxlite/stubs.go | 1 + apps/runner/pkg/common/errors.go | 14 + apps/runner/pkg/common/errors_test.go | 40 ++ apps/runner/pkg/runner/v2/executor/box.go | 96 ++++- .../v2/executor/box_capabilities_test.go | 328 ++++++++++++++ .../runner/pkg/runner/v2/executor/executor.go | 4 + .../pkg/runner/v2/healthcheck/healthcheck.go | 1 + docs/architecture/README.md | 2 + docs/architecture/container-capabilities.md | 155 +++++++ docs/reference/README.md | 20 + docs/reference/c/README.md | 44 ++ docs/reference/cli/README.md | 21 +- docs/reference/nodejs/README.md | 23 + docs/reference/python/README.md | 19 + docs/reference/rust/README.md | 12 +- make/test.mk | 16 + openapi/box.openapi.yaml | 180 +++++++- openapi/reference-server/server.py | 97 ++++- .../tests/test_handle_cache.py | 60 +++ sdks/c/README.md | 26 ++ sdks/c/include/boxlite.h | 66 ++- sdks/c/src/advanced_options.rs | 94 +++- sdks/c/src/event_queue.rs | 24 +- sdks/c/src/info.rs | 393 ++++++++++++++++- sdks/c/src/lib.rs | 4 + sdks/c/src/options.rs | 2 +- sdks/c/src/runtime.rs | 10 + sdks/c/src/tests.rs | 154 +++++++ sdks/go/README.md | 15 + sdks/go/advanced_options.go | 61 ++- sdks/go/boxlite_test.go | 68 +++ sdks/go/bridge.c | 8 +- sdks/go/bridge.h | 4 +- sdks/go/bridge_callback.go | 24 +- sdks/go/info.go | 74 +++- sdks/go/options.go | 43 +- sdks/go/runtime.go | 2 + sdks/node/README.md | 7 + sdks/node/lib/index.ts | 2 + sdks/node/lib/native-contracts.ts | 20 + sdks/node/lib/simplebox.ts | 24 ++ sdks/node/src/advanced_options.rs | 30 +- sdks/node/src/info.rs | 25 ++ sdks/node/src/lib.rs | 7 +- sdks/node/src/options.rs | 34 +- sdks/node/src/runtime.rs | 3 +- sdks/node/tests/options.test.ts | 40 +- sdks/python/README.md | 12 + sdks/python/boxlite/__init__.py | 6 + sdks/python/boxlite/sync_api/_boxlite.py | 4 +- sdks/python/src/advanced_options.rs | 50 ++- sdks/python/src/info.rs | 38 ++ sdks/python/src/lib.rs | 8 +- sdks/python/src/options.rs | 3 +- sdks/python/tests/test_options.py | 21 + src/boxlite/src/db/migration/mod.rs | 2 + src/boxlite/src/db/migration/v8_to_v9.rs | 34 ++ src/boxlite/src/db/mod.rs | 29 ++ src/boxlite/src/db/schema.rs | 2 +- src/boxlite/src/lib.rs | 6 +- src/boxlite/src/litebox/archive.rs | 41 +- src/boxlite/src/litebox/clone_export.rs | 5 +- .../src/litebox/init/tasks/guest_init.rs | 175 ++++---- .../src/portal/interfaces/container.rs | 105 +++-- src/boxlite/src/portal/interfaces/guest.rs | 41 ++ src/boxlite/src/portal/interfaces/mod.rs | 2 +- src/boxlite/src/rest/client.rs | 59 ++- src/boxlite/src/rest/runtime.rs | 368 ++++++++++++++-- src/boxlite/src/rest/types.rs | 120 +++++- src/boxlite/src/runtime/advanced_options.rs | 107 ++++- src/boxlite/src/runtime/core.rs | 3 +- src/boxlite/src/runtime/import.rs | 86 +++- src/boxlite/src/runtime/options.rs | 235 +++++++++- src/boxlite/src/runtime/rt_impl.rs | 11 +- src/boxlite/src/runtime/types.rs | 20 + src/boxlite/tests/security_enforcement.rs | 71 ++++ src/cli/README.md | 6 + src/cli/src/cli.rs | 22 + src/cli/src/commands/create.rs | 31 +- src/cli/src/commands/inspect.rs | 67 +++ src/cli/src/commands/run.rs | 30 +- src/cli/src/commands/serve/handlers/boxes.rs | 19 + src/cli/src/commands/serve/handlers/config.rs | 1 + src/cli/src/commands/serve/mod.rs | 32 +- src/cli/src/commands/serve/types.rs | 50 +++ src/guest/src/container/capabilities.rs | 400 +++++++++++++++--- src/guest/src/container/command.rs | 8 + src/guest/src/container/lifecycle.rs | 8 + src/guest/src/container/mod.rs | 2 + src/guest/src/container/spec.rs | 61 ++- src/guest/src/container/start.rs | 3 + src/guest/src/container/zygote.rs | 25 +- src/guest/src/service/container.rs | 15 +- src/guest/src/service/guest.rs | 3 + src/shared/proto/boxlite/v1/service.proto | 24 ++ src/shared/src/constants.rs | 6 + src/shared/src/errors.rs | 27 ++ 181 files changed, 8467 insertions(+), 578 deletions(-) create mode 100644 apps/api-client-go/contracttest/model_linux_capabilities_test.go create mode 100644 apps/api-client-go/model_box_advanced_options.go create mode 100644 apps/api-client-go/model_linux_capabilities.go create mode 100644 apps/api-client-go/model_runner_healthcheck_test.go create mode 100644 apps/api/src/box/common/box-advanced-options.ts create mode 100644 apps/api/src/box/constants/runner-features.spec.ts create mode 100644 apps/api/src/box/constants/runner-features.ts create mode 100644 apps/api/src/box/dto/box-advanced-options.dto.ts create mode 100644 apps/api/src/box/runner-adapter/runnerAdapter.v0.spec.ts create mode 100644 apps/api/src/box/runner-adapter/runnerAdapter.v2.spec.ts create mode 100644 apps/api/src/box/utils/capability-validation.util.ts create mode 100644 apps/api/src/boxlite-rest/boxlite-config.controller.spec.ts create mode 100644 apps/api/src/migrations/pre-deploy/1785000000000-add-box-capabilities-migration.spec.ts create mode 100644 apps/api/src/migrations/pre-deploy/1785000000000-add-box-capabilities-migration.ts create mode 100644 apps/libs/api-client/src/docs/BoxAdvancedOptions.md create mode 100644 apps/libs/api-client/src/docs/LinuxCapabilities.md create mode 100644 apps/libs/api-client/src/models/box-advanced-options.ts create mode 100644 apps/libs/api-client/src/models/linux-capabilities.ts create mode 100644 apps/libs/runner-api-client/src/docs/AdvancedBoxOptionsDTO.md create mode 100644 apps/libs/runner-api-client/src/docs/ContainerCapabilitiesDTO.md create mode 100644 apps/libs/runner-api-client/src/docs/CreateBoxWithCapabilitiesDTO.md create mode 100644 apps/libs/runner-api-client/src/docs/RecoverBoxWithCapabilitiesDTO.md create mode 100644 apps/libs/runner-api-client/src/models/advanced-box-options-dto.ts create mode 100644 apps/libs/runner-api-client/src/models/container-capabilities-dto.ts create mode 100644 apps/libs/runner-api-client/src/models/create-box-with-capabilities-dto.ts create mode 100644 apps/libs/runner-api-client/src/models/recover-box-with-capabilities-dto.ts create mode 100644 apps/runner/internal/features.go create mode 100644 apps/runner/pkg/api/controllers/box_capabilities_test.go create mode 100644 apps/runner/pkg/api/dto/box_capabilities_test.go create mode 100644 apps/runner/pkg/common/errors_test.go create mode 100644 apps/runner/pkg/runner/v2/executor/box_capabilities_test.go create mode 100644 docs/architecture/container-capabilities.md create mode 100644 src/boxlite/src/db/migration/v8_to_v9.rs diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index f755ea092..7c1c9fc66 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -101,7 +101,7 @@ jobs: - name: Install system dependencies (Linux) if: runner.os == 'Linux' - run: sudo apt-get update && sudo apt-get install -y protobuf-compiler + run: sudo apt-get update && sudo apt-get install -y libseccomp-dev protobuf-compiler - name: Install system dependencies (macOS) if: runner.os == 'macOS' @@ -117,6 +117,10 @@ jobs: # Only test boxlite-shared - boxlite requires libkrun/libgvproxy not available in CI run: cargo llvm-cov nextest -p boxlite-shared --lib --profile ci --lcov --output-path lcov.info + - name: Run guest capability policy tests + if: runner.os == 'Linux' + run: make test:unit:guest-capabilities + - name: Upload coverage to Codecov uses: codecov/codecov-action@v5 with: diff --git a/apps/api-client-go/.openapi-generator/FILES b/apps/api-client-go/.openapi-generator/FILES index c8e5ffcc9..673fef20b 100644 --- a/apps/api-client-go/.openapi-generator/FILES +++ b/apps/api-client-go/.openapi-generator/FILES @@ -54,6 +54,7 @@ model_api_key_list.go model_api_key_response.go model_audit_log.go model_box.go +model_box_advanced_options.go model_box_class.go model_box_desired_state.go model_box_labels.go @@ -77,6 +78,7 @@ model_job.go model_job_status.go model_job_type.go model_log_entry.go +model_linux_capabilities.go model_metric_data_point.go model_metric_series.go model_metrics_response.go diff --git a/apps/api-client-go/api/openapi.yaml b/apps/api-client-go/api/openapi.yaml index e18d88137..62add57f1 100644 --- a/apps/api-client-go/api/openapi.yaml +++ b/apps/api-client-go/api/openapi.yaml @@ -6306,6 +6306,41 @@ components: - mountPath - volumeId type: object + LinuxCapabilities: + example: + add: + - SYS_ADMIN + drop: + - NET_RAW + properties: + add: + description: Linux capabilities added to the default container capability + set + items: + type: string + type: array + drop: + description: Linux capabilities removed from the container capability set + items: + type: string + type: array + required: + - add + - drop + type: object + BoxAdvancedOptions: + example: + capabilities: + add: + - SYS_ADMIN + drop: + - NET_RAW + properties: + capabilities: + $ref: "#/components/schemas/LinuxCapabilities" + required: + - capabilities + type: object Box: example: id: aB3cD4eF5gH6 @@ -6314,6 +6349,12 @@ components: user: boxlite env: NODE_ENV: production + advanced: + capabilities: + add: + - SYS_ADMIN + drop: + - NET_RAW labels: boxlite.io/public: "true" public: false @@ -6369,6 +6410,10 @@ components: example: NODE_ENV: production type: object + advanced: + allOf: + - $ref: "#/components/schemas/BoxAdvancedOptions" + description: Advanced box configuration labels: additionalProperties: type: string @@ -6478,6 +6523,7 @@ components: example: https://proxy.app.boxlite.io/toolbox type: string required: + - advanced - cpu - disk - env @@ -7249,6 +7295,8 @@ components: proxyUrl: http://proxy.boxlite.example.com:8080 apiUrl: http://api.boxlite.example.com:8080 appVersion: v0.0.0-dev + features: + - linux-capabilities-v2 properties: metrics: allOf: @@ -7275,6 +7323,13 @@ components: description: Runner app version example: v0.0.0-dev type: string + features: + description: Optional runner features used for rollout negotiation + example: + - linux-capabilities-v2 + items: + type: string + type: array required: - appVersion type: object @@ -7364,6 +7419,7 @@ components: description: The type of the job enum: - CREATE_BOX + - CREATE_BOX_WITH_CAPABILITIES_V2 - START_BOX - STOP_BOX - DESTROY_BOX @@ -7371,6 +7427,7 @@ components: - CREATE_BACKUP - PULL_ARTIFACT - RECOVER_BOX + - RECOVER_BOX_WITH_CAPABILITIES_V2 - INSPECT_ARTIFACT_IN_REGISTRY - REMOVE_ARTIFACT - UPDATE_BOX_NETWORK_SETTINGS diff --git a/apps/api-client-go/contracttest/model_linux_capabilities_test.go b/apps/api-client-go/contracttest/model_linux_capabilities_test.go new file mode 100644 index 000000000..dfc7de51d --- /dev/null +++ b/apps/api-client-go/contracttest/model_linux_capabilities_test.go @@ -0,0 +1,71 @@ +package contracttest + +import ( + "encoding/json" + "strings" + "testing" + + apiclient "github.com/boxlite-ai/boxlite/libs/api-client-go" +) + +func TestCapabilityModelsRejectNullRequiredValues(t *testing.T) { + tests := []struct { + name string + payload string + target func() any + wantErrorProperty string + }{ + { + name: "add", + payload: `{"add":null,"drop":[]}`, + target: func() any { return &apiclient.LinuxCapabilities{} }, + wantErrorProperty: "add", + }, + { + name: "drop", + payload: `{"add":[],"drop":null}`, + target: func() any { return &apiclient.LinuxCapabilities{} }, + wantErrorProperty: "drop", + }, + { + name: "capabilities", + payload: `{"capabilities":null}`, + target: func() any { return &apiclient.BoxAdvancedOptions{} }, + wantErrorProperty: "add", + }, + { + name: "advanced", + payload: `{ + "id":"box-1", + "organizationId":"org-1", + "name":"box", + "user":"boxlite", + "env":{}, + "advanced":null, + "labels":{}, + "public":false, + "networkBlockAll":false, + "target":"local", + "cpu":1, + "gpu":0, + "memory":1, + "disk":10, + "toolboxProxyUrl":"" + }`, + target: func() any { return &apiclient.Box{} }, + wantErrorProperty: "capabilities", + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + err := json.Unmarshal([]byte(test.payload), test.target()) + if err == nil { + t.Fatalf("expected %s to reject null", test.name) + } + if !strings.Contains(err.Error(), "required property "+test.wantErrorProperty) { + t.Fatalf("unexpected error for %s: %v", test.name, err) + } + }) + } +} diff --git a/apps/api-client-go/model_box.go b/apps/api-client-go/model_box.go index 87053b3a7..da1d6bf79 100644 --- a/apps/api-client-go/model_box.go +++ b/apps/api-client-go/model_box.go @@ -31,6 +31,8 @@ type Box struct { User string `json:"user"` // Environment variables for the box Env map[string]string `json:"env"` + // Advanced box configuration + Advanced BoxAdvancedOptions `json:"advanced"` // Labels for the box Labels map[string]string `json:"labels"` // Whether the box http preview is public @@ -79,7 +81,7 @@ type Box struct { // The runner ID of the box RunnerId *string `json:"runnerId,omitempty"` // The toolbox proxy URL for the box - ToolboxProxyUrl string `json:"toolboxProxyUrl"` + ToolboxProxyUrl string `json:"toolboxProxyUrl"` AdditionalProperties map[string]interface{} } @@ -89,13 +91,14 @@ type _Box Box // 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 NewBox(id string, organizationId string, name string, user string, env map[string]string, labels map[string]string, public bool, networkBlockAll bool, target string, cpu float32, gpu float32, memory float32, disk float32, toolboxProxyUrl string) *Box { +func NewBox(id string, organizationId string, name string, user string, env map[string]string, advanced BoxAdvancedOptions, labels map[string]string, public bool, networkBlockAll bool, target string, cpu float32, gpu float32, memory float32, disk float32, toolboxProxyUrl string) *Box { this := Box{} this.Id = id this.OrganizationId = organizationId this.Name = name this.User = user this.Env = env + this.Advanced = advanced this.Labels = labels this.Public = public this.NetworkBlockAll = networkBlockAll @@ -236,6 +239,29 @@ func (o *Box) SetEnv(v map[string]string) { o.Env = v } +// GetAdvanced returns the Advanced field value. +func (o *Box) GetAdvanced() BoxAdvancedOptions { + if o == nil { + var ret BoxAdvancedOptions + return ret + } + return o.Advanced +} + +// GetAdvancedOk returns a tuple with the Advanced field value +// and a boolean to check if the value has been set. +func (o *Box) GetAdvancedOk() (*BoxAdvancedOptions, bool) { + if o == nil { + return nil, false + } + return &o.Advanced, true +} + +// SetAdvanced sets field value. +func (o *Box) SetAdvanced(v BoxAdvancedOptions) { + o.Advanced = v +} + // GetLabels returns the Labels field value func (o *Box) GetLabels() map[string]string { if o == nil { @@ -936,7 +962,7 @@ func (o *Box) SetToolboxProxyUrl(v string) { } func (o Box) MarshalJSON() ([]byte, error) { - toSerialize,err := o.ToMap() + toSerialize, err := o.ToMap() if err != nil { return []byte{}, err } @@ -950,6 +976,7 @@ func (o Box) ToMap() (map[string]interface{}, error) { toSerialize["name"] = o.Name toSerialize["user"] = o.User toSerialize["env"] = o.Env + toSerialize["advanced"] = o.Advanced toSerialize["labels"] = o.Labels toSerialize["public"] = o.Public toSerialize["networkBlockAll"] = o.NetworkBlockAll @@ -1022,6 +1049,7 @@ func (o *Box) UnmarshalJSON(data []byte) (err error) { "name", "user", "env", + "advanced", "labels", "public", "networkBlockAll", @@ -1038,10 +1066,10 @@ func (o *Box) UnmarshalJSON(data []byte) (err error) { err = json.Unmarshal(data, &allProperties) if err != nil { - return err; + return err } - for _, requiredProperty := range(requiredProperties) { + for _, requiredProperty := range requiredProperties { if _, exists := allProperties[requiredProperty]; !exists { return fmt.Errorf("no value given for required property %v", requiredProperty) } @@ -1065,6 +1093,7 @@ func (o *Box) UnmarshalJSON(data []byte) (err error) { delete(additionalProperties, "name") delete(additionalProperties, "user") delete(additionalProperties, "env") + delete(additionalProperties, "advanced") delete(additionalProperties, "labels") delete(additionalProperties, "public") delete(additionalProperties, "networkBlockAll") @@ -1130,5 +1159,3 @@ func (v *NullableBox) UnmarshalJSON(src []byte) error { v.isSet = true return json.Unmarshal(src, &v.value) } - - diff --git a/apps/api-client-go/model_box_advanced_options.go b/apps/api-client-go/model_box_advanced_options.go new file mode 100644 index 000000000..4202ef073 --- /dev/null +++ b/apps/api-client-go/model_box_advanced_options.go @@ -0,0 +1,123 @@ +/* +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" +) + +var _ MappedNullable = &BoxAdvancedOptions{} + +// BoxAdvancedOptions contains advanced box configuration. +type BoxAdvancedOptions struct { + Capabilities LinuxCapabilities `json:"capabilities"` + AdditionalProperties map[string]interface{} +} + +type _BoxAdvancedOptions BoxAdvancedOptions + +func NewBoxAdvancedOptions(capabilities LinuxCapabilities) *BoxAdvancedOptions { + this := BoxAdvancedOptions{} + this.Capabilities = capabilities + return &this +} + +func NewBoxAdvancedOptionsWithDefaults() *BoxAdvancedOptions { + this := BoxAdvancedOptions{} + return &this +} + +func (o *BoxAdvancedOptions) GetCapabilities() LinuxCapabilities { + if o == nil { + var ret LinuxCapabilities + return ret + } + return o.Capabilities +} + +func (o *BoxAdvancedOptions) GetCapabilitiesOk() (*LinuxCapabilities, bool) { + if o == nil { + return nil, false + } + return &o.Capabilities, true +} + +func (o *BoxAdvancedOptions) SetCapabilities(v LinuxCapabilities) { + o.Capabilities = v +} + +func (o BoxAdvancedOptions) MarshalJSON() ([]byte, error) { + toSerialize, err := o.ToMap() + if err != nil { + return []byte{}, err + } + return json.Marshal(toSerialize) +} + +func (o BoxAdvancedOptions) ToMap() (map[string]interface{}, error) { + toSerialize := map[string]interface{}{ + "capabilities": o.Capabilities, + } + for key, value := range o.AdditionalProperties { + toSerialize[key] = value + } + return toSerialize, nil +} + +func (o *BoxAdvancedOptions) UnmarshalJSON(data []byte) error { + allProperties := make(map[string]interface{}) + if err := json.Unmarshal(data, &allProperties); err != nil { + return err + } + if _, exists := allProperties["capabilities"]; !exists { + return fmt.Errorf("no value given for required property capabilities") + } + + varBoxAdvancedOptions := _BoxAdvancedOptions{} + if err := json.Unmarshal(data, &varBoxAdvancedOptions); err != nil { + return err + } + *o = BoxAdvancedOptions(varBoxAdvancedOptions) + + additionalProperties := make(map[string]interface{}) + if err := json.Unmarshal(data, &additionalProperties); err != nil { + return err + } + delete(additionalProperties, "capabilities") + o.AdditionalProperties = additionalProperties + return nil +} + +type NullableBoxAdvancedOptions struct { + value *BoxAdvancedOptions + isSet bool +} + +func (v NullableBoxAdvancedOptions) Get() *BoxAdvancedOptions { return v.value } +func (v *NullableBoxAdvancedOptions) Set(val *BoxAdvancedOptions) { + v.value = val + v.isSet = true +} +func (v NullableBoxAdvancedOptions) IsSet() bool { return v.isSet } +func (v *NullableBoxAdvancedOptions) Unset() { + v.value = nil + v.isSet = false +} +func NewNullableBoxAdvancedOptions(val *BoxAdvancedOptions) *NullableBoxAdvancedOptions { + return &NullableBoxAdvancedOptions{value: val, isSet: true} +} +func (v NullableBoxAdvancedOptions) MarshalJSON() ([]byte, error) { return json.Marshal(v.value) } +func (v *NullableBoxAdvancedOptions) UnmarshalJSON(src []byte) error { + v.isSet = true + return json.Unmarshal(src, &v.value) +} diff --git a/apps/api-client-go/model_job_type.go b/apps/api-client-go/model_job_type.go index 530dccd43..c2d695a6b 100644 --- a/apps/api-client-go/model_job_type.go +++ b/apps/api-client-go/model_job_type.go @@ -21,6 +21,7 @@ type JobType string // List of JobType const ( JOBTYPE_CREATE_BOX JobType = "CREATE_BOX" + JOBTYPE_CREATE_BOX_WITH_CAPABILITIES_V2 JobType = "CREATE_BOX_WITH_CAPABILITIES_V2" JOBTYPE_START_BOX JobType = "START_BOX" JOBTYPE_STOP_BOX JobType = "STOP_BOX" JOBTYPE_DESTROY_BOX JobType = "DESTROY_BOX" @@ -28,6 +29,7 @@ const ( JOBTYPE_CREATE_BACKUP JobType = "CREATE_BACKUP" JOBTYPE_PULL_ARTIFACT JobType = "PULL_ARTIFACT" JOBTYPE_RECOVER_BOX JobType = "RECOVER_BOX" + JOBTYPE_RECOVER_BOX_WITH_CAPABILITIES_V2 JobType = "RECOVER_BOX_WITH_CAPABILITIES_V2" JOBTYPE_INSPECT_ARTIFACT_IN_REGISTRY JobType = "INSPECT_ARTIFACT_IN_REGISTRY" JOBTYPE_REMOVE_ARTIFACT JobType = "REMOVE_ARTIFACT" JOBTYPE_UPDATE_BOX_NETWORK_SETTINGS JobType = "UPDATE_BOX_NETWORK_SETTINGS" @@ -37,6 +39,7 @@ const ( // All allowed values of JobType enum var AllowedJobTypeEnumValues = []JobType{ "CREATE_BOX", + "CREATE_BOX_WITH_CAPABILITIES_V2", "START_BOX", "STOP_BOX", "DESTROY_BOX", @@ -44,6 +47,7 @@ var AllowedJobTypeEnumValues = []JobType{ "CREATE_BACKUP", "PULL_ARTIFACT", "RECOVER_BOX", + "RECOVER_BOX_WITH_CAPABILITIES_V2", "INSPECT_ARTIFACT_IN_REGISTRY", "REMOVE_ARTIFACT", "UPDATE_BOX_NETWORK_SETTINGS", @@ -130,4 +134,3 @@ func (v *NullableJobType) UnmarshalJSON(src []byte) error { v.isSet = true return json.Unmarshal(src, &v.value) } - diff --git a/apps/api-client-go/model_linux_capabilities.go b/apps/api-client-go/model_linux_capabilities.go new file mode 100644 index 000000000..606966516 --- /dev/null +++ b/apps/api-client-go/model_linux_capabilities.go @@ -0,0 +1,150 @@ +/* +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" +) + +var _ MappedNullable = &LinuxCapabilities{} + +// LinuxCapabilities is the effective Linux capability policy for a box. +type LinuxCapabilities struct { + // Linux capabilities added to the default container capability set + Add []string `json:"add"` + // Linux capabilities removed from the container capability set + Drop []string `json:"drop"` + AdditionalProperties map[string]interface{} +} + +type _LinuxCapabilities LinuxCapabilities + +func NewLinuxCapabilities(add []string, drop []string) *LinuxCapabilities { + this := LinuxCapabilities{} + this.Add = add + this.Drop = drop + return &this +} + +func NewLinuxCapabilitiesWithDefaults() *LinuxCapabilities { + this := LinuxCapabilities{} + return &this +} + +func (o *LinuxCapabilities) GetAdd() []string { + if o == nil { + var ret []string + return ret + } + return o.Add +} + +func (o *LinuxCapabilities) GetAddOk() (*[]string, bool) { + if o == nil { + return nil, false + } + return &o.Add, true +} + +func (o *LinuxCapabilities) SetAdd(v []string) { + o.Add = v +} + +func (o *LinuxCapabilities) GetDrop() []string { + if o == nil { + var ret []string + return ret + } + return o.Drop +} + +func (o *LinuxCapabilities) GetDropOk() (*[]string, bool) { + if o == nil { + return nil, false + } + return &o.Drop, true +} + +func (o *LinuxCapabilities) SetDrop(v []string) { + o.Drop = v +} + +func (o LinuxCapabilities) MarshalJSON() ([]byte, error) { + toSerialize, err := o.ToMap() + if err != nil { + return []byte{}, err + } + return json.Marshal(toSerialize) +} + +func (o LinuxCapabilities) ToMap() (map[string]interface{}, error) { + toSerialize := map[string]interface{}{ + "add": o.Add, + "drop": o.Drop, + } + for key, value := range o.AdditionalProperties { + toSerialize[key] = value + } + return toSerialize, nil +} + +func (o *LinuxCapabilities) UnmarshalJSON(data []byte) error { + allProperties := make(map[string]interface{}) + if err := json.Unmarshal(data, &allProperties); err != nil { + return err + } + for _, requiredProperty := range []string{"add", "drop"} { + if value, exists := allProperties[requiredProperty]; !exists || value == nil { + return fmt.Errorf("no value given for required property %v", requiredProperty) + } + } + + varLinuxCapabilities := _LinuxCapabilities{} + if err := json.Unmarshal(data, &varLinuxCapabilities); err != nil { + return err + } + *o = LinuxCapabilities(varLinuxCapabilities) + + additionalProperties := make(map[string]interface{}) + if err := json.Unmarshal(data, &additionalProperties); err != nil { + return err + } + delete(additionalProperties, "add") + delete(additionalProperties, "drop") + o.AdditionalProperties = additionalProperties + return nil +} + +type NullableLinuxCapabilities struct { + value *LinuxCapabilities + isSet bool +} + +func (v NullableLinuxCapabilities) Get() *LinuxCapabilities { return v.value } +func (v *NullableLinuxCapabilities) Set(val *LinuxCapabilities) { + v.value = val + v.isSet = true +} +func (v NullableLinuxCapabilities) IsSet() bool { return v.isSet } +func (v *NullableLinuxCapabilities) Unset() { + v.value = nil + v.isSet = false +} +func NewNullableLinuxCapabilities(val *LinuxCapabilities) *NullableLinuxCapabilities { + return &NullableLinuxCapabilities{value: val, isSet: true} +} +func (v NullableLinuxCapabilities) MarshalJSON() ([]byte, error) { return json.Marshal(v.value) } +func (v *NullableLinuxCapabilities) UnmarshalJSON(src []byte) error { + v.isSet = true + return json.Unmarshal(src, &v.value) +} diff --git a/apps/api-client-go/model_runner_healthcheck.go b/apps/api-client-go/model_runner_healthcheck.go index b2d8fa500..c67c456f4 100644 --- a/apps/api-client-go/model_runner_healthcheck.go +++ b/apps/api-client-go/model_runner_healthcheck.go @@ -21,6 +21,8 @@ var _ MappedNullable = &RunnerHealthcheck{} // RunnerHealthcheck struct for RunnerHealthcheck type RunnerHealthcheck struct { + // Optional runner features used for rollout negotiation + Features []string `json:"features,omitempty"` // Runner metrics Metrics *RunnerHealthMetrics `json:"metrics,omitempty"` // Health status of individual services on the runner @@ -32,7 +34,7 @@ type RunnerHealthcheck struct { // Runner API URL ApiUrl *string `json:"apiUrl,omitempty"` // Runner app version - AppVersion string `json:"appVersion"` + AppVersion string `json:"appVersion"` AdditionalProperties map[string]interface{} } @@ -56,6 +58,33 @@ func NewRunnerHealthcheckWithDefaults() *RunnerHealthcheck { return &this } +// GetFeatures returns the Features field value if set, zero value otherwise. +func (o *RunnerHealthcheck) GetFeatures() []string { + if o == nil || IsNil(o.Features) { + var ret []string + return ret + } + return o.Features +} + +// GetFeaturesOk returns a tuple with the Features field value if set, nil otherwise. +func (o *RunnerHealthcheck) GetFeaturesOk() ([]string, bool) { + if o == nil || IsNil(o.Features) { + return nil, false + } + return o.Features, true +} + +// HasFeatures returns true if Features has been set. +func (o *RunnerHealthcheck) HasFeatures() bool { + return o != nil && !IsNil(o.Features) +} + +// SetFeatures sets the Features field value. +func (o *RunnerHealthcheck) SetFeatures(v []string) { + o.Features = v +} + // GetMetrics returns the Metrics field value if set, zero value otherwise. func (o *RunnerHealthcheck) GetMetrics() RunnerHealthMetrics { if o == nil || IsNil(o.Metrics) { @@ -241,7 +270,7 @@ func (o *RunnerHealthcheck) SetAppVersion(v string) { } func (o RunnerHealthcheck) MarshalJSON() ([]byte, error) { - toSerialize,err := o.ToMap() + toSerialize, err := o.ToMap() if err != nil { return []byte{}, err } @@ -250,6 +279,9 @@ func (o RunnerHealthcheck) MarshalJSON() ([]byte, error) { func (o RunnerHealthcheck) ToMap() (map[string]interface{}, error) { toSerialize := map[string]interface{}{} + if !IsNil(o.Features) { + toSerialize["features"] = o.Features + } if !IsNil(o.Metrics) { toSerialize["metrics"] = o.Metrics } @@ -287,10 +319,10 @@ func (o *RunnerHealthcheck) UnmarshalJSON(data []byte) (err error) { err = json.Unmarshal(data, &allProperties) if err != nil { - return err; + return err } - for _, requiredProperty := range(requiredProperties) { + for _, requiredProperty := range requiredProperties { if _, exists := allProperties[requiredProperty]; !exists { return fmt.Errorf("no value given for required property %v", requiredProperty) } @@ -309,6 +341,7 @@ func (o *RunnerHealthcheck) UnmarshalJSON(data []byte) (err error) { additionalProperties := make(map[string]interface{}) if err = json.Unmarshal(data, &additionalProperties); err == nil { + delete(additionalProperties, "features") delete(additionalProperties, "metrics") delete(additionalProperties, "serviceHealth") delete(additionalProperties, "domain") @@ -356,5 +389,3 @@ func (v *NullableRunnerHealthcheck) UnmarshalJSON(src []byte) error { v.isSet = true return json.Unmarshal(src, &v.value) } - - diff --git a/apps/api-client-go/model_runner_healthcheck_test.go b/apps/api-client-go/model_runner_healthcheck_test.go new file mode 100644 index 000000000..31b4b31f9 --- /dev/null +++ b/apps/api-client-go/model_runner_healthcheck_test.go @@ -0,0 +1,71 @@ +package apiclient + +import ( + "encoding/json" + "reflect" + "testing" +) + +func TestRunnerHealthcheckCarriesAdvertisedFeatures(t *testing.T) { + healthcheck := NewRunnerHealthcheck("v1.0.0") + healthcheck.SetFeatures([]string{"linux-capabilities-v2"}) + + payload, err := json.Marshal(healthcheck) + if err != nil { + t.Fatalf("marshal healthcheck: %v", err) + } + + var wire map[string]any + if err := json.Unmarshal(payload, &wire); err != nil { + t.Fatalf("decode healthcheck: %v", err) + } + if !reflect.DeepEqual(wire["features"], []any{"linux-capabilities-v2"}) { + t.Fatalf("features lost from healthcheck: %s", payload) + } +} + +func TestRunnerHealthcheckDoesNotTreatFeaturesAsAdditional(t *testing.T) { + var healthcheck RunnerHealthcheck + if err := json.Unmarshal([]byte(`{"appVersion":"v1.0.0","features":["linux-capabilities-v2"]}`), &healthcheck); err != nil { + t.Fatalf("unmarshal healthcheck: %v", err) + } + + if _, exists := healthcheck.AdditionalProperties["features"]; exists { + t.Fatal("known features field must not remain in AdditionalProperties") + } +} + +func TestBoxDoesNotTreatCapabilitiesAsAdditional(t *testing.T) { + box := NewBox( + "box-1", + "org-1", + "cap-box", + "boxlite", + map[string]string{}, + *NewBoxAdvancedOptions(*NewLinuxCapabilities([]string{"SYS_ADMIN"}, []string{"NET_RAW"})), + map[string]string{}, + true, + false, + "local", + 1, + 0, + 1, + 10, + "https://example.test/toolbox", + ) + payload, err := json.Marshal(box) + if err != nil { + t.Fatalf("marshal box: %v", err) + } + + var decoded Box + if err := json.Unmarshal(payload, &decoded); err != nil { + t.Fatalf("unmarshal box: %v", err) + } + if _, exists := decoded.AdditionalProperties["advanced"]; exists { + t.Fatal("known advanced field must not remain in AdditionalProperties") + } + if !reflect.DeepEqual(decoded.Advanced.Capabilities.Add, []string{"SYS_ADMIN"}) { + t.Fatalf("advanced capabilities lost during round trip: %s", payload) + } +} diff --git a/apps/api/src/box/common/box-advanced-options.ts b/apps/api/src/box/common/box-advanced-options.ts new file mode 100644 index 000000000..87eedb4ee --- /dev/null +++ b/apps/api/src/box/common/box-advanced-options.ts @@ -0,0 +1,28 @@ +export interface LinuxCapabilities { + add: string[] + drop: string[] +} + +export interface BoxAdvancedOptions { + capabilities: LinuxCapabilities +} + +export function normalizeBoxAdvancedOptions( + advanced?: { + capabilities?: { + add?: readonly string[] | null + drop?: readonly string[] | null + } | null + } | null, +): BoxAdvancedOptions { + return { + capabilities: { + add: [...(advanced?.capabilities?.add ?? [])], + drop: [...(advanced?.capabilities?.drop ?? [])], + }, + } +} + +export function hasCapabilityPolicy(advanced?: BoxAdvancedOptions | null): boolean { + return !!(advanced?.capabilities.add.length || advanced?.capabilities.drop.length) +} diff --git a/apps/api/src/box/constants/runner-features.spec.ts b/apps/api/src/box/constants/runner-features.spec.ts new file mode 100644 index 000000000..ab0fc067f --- /dev/null +++ b/apps/api/src/box/constants/runner-features.spec.ts @@ -0,0 +1,31 @@ +import { RUNNER_FEATURES, requiredRunnerFeaturesForCapabilities, runnerSupportsFeatures } from './runner-features' + +describe('runner feature negotiation', () => { + it('requires every requested feature and treats old runners as unsupported', () => { + expect(runnerSupportsFeatures(undefined, [RUNNER_FEATURES.LINUX_CAPABILITIES_V2])).toBe(false) + expect(runnerSupportsFeatures([], [RUNNER_FEATURES.LINUX_CAPABILITIES_V2])).toBe(false) + expect(runnerSupportsFeatures(['other'], [RUNNER_FEATURES.LINUX_CAPABILITIES_V2])).toBe(false) + expect( + runnerSupportsFeatures( + [RUNNER_FEATURES.LINUX_CAPABILITIES_V2], + [RUNNER_FEATURES.LINUX_CAPABILITIES_V2], + ), + ).toBe(true) + }) + + it('does not constrain ordinary boxes', () => { + expect(runnerSupportsFeatures(undefined, undefined)).toBe(true) + expect(runnerSupportsFeatures(undefined, [])).toBe(true) + }) + + it('derives the feature requirement from either capability list', () => { + expect(requiredRunnerFeaturesForCapabilities(undefined)).toEqual([]) + expect(requiredRunnerFeaturesForCapabilities({ add: [], drop: [] })).toEqual([]) + expect(requiredRunnerFeaturesForCapabilities({ add: ['SYS_PTRACE'], drop: [] })).toEqual([ + RUNNER_FEATURES.LINUX_CAPABILITIES_V2, + ]) + expect(requiredRunnerFeaturesForCapabilities({ add: [], drop: ['NET_RAW'] })).toEqual([ + RUNNER_FEATURES.LINUX_CAPABILITIES_V2, + ]) + }) +}) diff --git a/apps/api/src/box/constants/runner-features.ts b/apps/api/src/box/constants/runner-features.ts new file mode 100644 index 000000000..244a36c4e --- /dev/null +++ b/apps/api/src/box/constants/runner-features.ts @@ -0,0 +1,22 @@ +import { LinuxCapabilities } from '../common/box-advanced-options' + +export const RUNNER_FEATURES = { + LINUX_CAPABILITIES_V2: 'linux-capabilities-v2', +} as const + +export function requiredRunnerFeaturesForCapabilities( + capabilities: Pick | null | undefined, +): string[] { + return capabilities?.add.length || capabilities?.drop.length ? [RUNNER_FEATURES.LINUX_CAPABILITIES_V2] : [] +} + +export function runnerSupportsFeatures( + advertised: readonly string[] | null | undefined, + required: readonly string[] | null | undefined, +): boolean { + if (!required?.length) { + return true + } + const available = new Set(advertised ?? []) + return required.every((feature) => available.has(feature)) +} diff --git a/apps/api/src/box/controllers/runner.controller.ts b/apps/api/src/box/controllers/runner.controller.ts index 95854fdc3..0e07f3b8b 100644 --- a/apps/api/src/box/controllers/runner.controller.ts +++ b/apps/api/src/box/controllers/runner.controller.ts @@ -338,6 +338,7 @@ export class RunnerController { healthcheck.serviceHealth, healthcheck.metrics, healthcheck.appVersion, + healthcheck.features, ) } } diff --git a/apps/api/src/box/dto/box-advanced-options.dto.ts b/apps/api/src/box/dto/box-advanced-options.dto.ts new file mode 100644 index 000000000..7e26b00aa --- /dev/null +++ b/apps/api/src/box/dto/box-advanced-options.dto.ts @@ -0,0 +1,30 @@ +/* + * Copyright 2025 BoxLite AI + * SPDX-License-Identifier: AGPL-3.0 + */ + +import { ApiProperty, ApiSchema } from '@nestjs/swagger' +import { BoxAdvancedOptions, LinuxCapabilities } from '../common/box-advanced-options' + +@ApiSchema({ name: 'LinuxCapabilities' }) +export class LinuxCapabilitiesDto implements LinuxCapabilities { + @ApiProperty({ + description: 'Linux capabilities added to the default container capability set', + type: [String], + example: ['SYS_ADMIN'], + }) + add: string[] + + @ApiProperty({ + description: 'Linux capabilities removed from the container capability set', + type: [String], + example: ['NET_RAW'], + }) + drop: string[] +} + +@ApiSchema({ name: 'BoxAdvancedOptions' }) +export class BoxAdvancedOptionsDto implements BoxAdvancedOptions { + @ApiProperty({ type: LinuxCapabilitiesDto }) + capabilities: LinuxCapabilitiesDto +} diff --git a/apps/api/src/box/dto/box.dto.spec.ts b/apps/api/src/box/dto/box.dto.spec.ts index e6c1f2916..afb64dc53 100644 --- a/apps/api/src/box/dto/box.dto.spec.ts +++ b/apps/api/src/box/dto/box.dto.spec.ts @@ -12,10 +12,13 @@ describe('BoxDto public identity', () => { const box = new Box('us', 'data-loader') box.organizationId = '057963b2-60ca-4356-81fc-11503e15f249' box.osUser = 'boxlite' + box.advanced = { capabilities: { add: ['SYS_ADMIN'], drop: ['NET_RAW'] } } const dto = BoxDto.fromBox(box, 'https://proxy.boxlite.dev/toolbox') expect(dto.id).toBe(box.id) expect((dto as any).boxId).toBeUndefined() + expect(dto.advanced.capabilities.add).toEqual(['SYS_ADMIN']) + expect(dto.advanced.capabilities.drop).toEqual(['NET_RAW']) }) }) diff --git a/apps/api/src/box/dto/box.dto.ts b/apps/api/src/box/dto/box.dto.ts index aedbe1001..7b3253165 100644 --- a/apps/api/src/box/dto/box.dto.ts +++ b/apps/api/src/box/dto/box.dto.ts @@ -10,6 +10,8 @@ import { IsEnum, IsOptional } from 'class-validator' import { Box } from '../entities/box.entity' import { BoxDesiredState } from '../enums/box-desired-state.enum' import { BoxClass } from '../enums/box-class.enum' +import { normalizeBoxAdvancedOptions } from '../common/box-advanced-options' +import { BoxAdvancedOptionsDto } from './box-advanced-options.dto' @ApiSchema({ name: 'BoxVolume' }) export class BoxVolume { @@ -67,6 +69,13 @@ export class BoxDto { }) env: Record + @ApiProperty({ + description: 'Advanced box configuration', + type: BoxAdvancedOptionsDto, + example: { capabilities: { add: ['SYS_ADMIN'], drop: ['NET_RAW'] } }, + }) + advanced: BoxAdvancedOptionsDto + @ApiProperty({ description: 'Labels for the box', type: 'object', @@ -261,6 +270,7 @@ export class BoxDto { image: box.image, user: box.osUser, env: box.env, + advanced: normalizeBoxAdvancedOptions(box.advanced), cpu: box.cpu, gpu: box.gpu, memory: box.mem, diff --git a/apps/api/src/box/dto/create-box.dto.ts b/apps/api/src/box/dto/create-box.dto.ts index be0d7ea93..e8be2788d 100644 --- a/apps/api/src/box/dto/create-box.dto.ts +++ b/apps/api/src/box/dto/create-box.dto.ts @@ -4,10 +4,60 @@ * SPDX-License-Identifier: AGPL-3.0 */ -import { IsEnum, IsObject, IsOptional, IsString, IsNumber, IsBoolean, IsArray, IsInt, Min } from 'class-validator' +import { + IsEnum, + IsObject, + IsOptional, + IsString, + IsNumber, + IsBoolean, + IsArray, + IsInt, + Min, + Validate, + ValidateIf, + ValidateNested, +} from 'class-validator' +import { Type } from 'class-transformer' import { ApiPropertyOptional, ApiSchema } from '@nestjs/swagger' import { BoxClass } from '../enums/box-class.enum' import { BoxVolume } from './box.dto' +import { IsLinuxCapabilityNameConstraint } from '../utils/capability-validation.util' + +@ApiSchema({ name: 'CreateLinuxCapabilities' }) +export class CreateLinuxCapabilitiesDto { + @ApiPropertyOptional({ + description: 'Linux capabilities to add to the default container capability set', + type: [String], + example: ['SYS_ADMIN'], + }) + @ValidateIf((_object, value) => value !== undefined) + @IsArray() + @IsString({ each: true }) + @Validate(IsLinuxCapabilityNameConstraint, { each: true }) + add?: string[] + + @ApiPropertyOptional({ + description: 'Linux capabilities to remove from the container capability set', + type: [String], + example: ['NET_RAW'], + }) + @ValidateIf((_object, value) => value !== undefined) + @IsArray() + @IsString({ each: true }) + @Validate(IsLinuxCapabilityNameConstraint, { each: true }) + drop?: string[] +} + +@ApiSchema({ name: 'CreateBoxAdvancedOptions' }) +export class CreateBoxAdvancedOptionsDto { + @ApiPropertyOptional({ type: CreateLinuxCapabilitiesDto }) + @ValidateIf((_object, value) => value !== undefined) + @IsObject() + @ValidateNested() + @Type(() => CreateLinuxCapabilitiesDto) + capabilities?: CreateLinuxCapabilitiesDto +} @ApiSchema({ name: 'CreateBox' }) export class CreateBoxDto { @@ -46,6 +96,13 @@ export class CreateBoxDto { @IsObject() env?: { [key: string]: string } + @ApiPropertyOptional({ type: CreateBoxAdvancedOptionsDto }) + @ValidateIf((_object, value) => value !== undefined) + @IsObject() + @ValidateNested() + @Type(() => CreateBoxAdvancedOptionsDto) + advanced?: CreateBoxAdvancedOptionsDto + @ApiPropertyOptional({ description: 'Labels for the box', type: 'object', diff --git a/apps/api/src/box/dto/job-type-map.dto.ts b/apps/api/src/box/dto/job-type-map.dto.ts index df29f2a59..bfe3e14c2 100644 --- a/apps/api/src/box/dto/job-type-map.dto.ts +++ b/apps/api/src/box/dto/job-type-map.dto.ts @@ -16,6 +16,9 @@ export interface JobTypeMap { [JobType.CREATE_BOX]: { resourceType: [ResourceType.BOX] } + [JobType.CREATE_BOX_WITH_CAPABILITIES_V2]: { + resourceType: [ResourceType.BOX] + } [JobType.START_BOX]: { resourceType: [ResourceType.BOX] } @@ -46,6 +49,9 @@ export interface JobTypeMap { [JobType.RECOVER_BOX]: { resourceType: [ResourceType.BOX] } + [JobType.RECOVER_BOX_WITH_CAPABILITIES_V2]: { + resourceType: [ResourceType.BOX] + } } /** diff --git a/apps/api/src/box/dto/runner-health.dto.ts b/apps/api/src/box/dto/runner-health.dto.ts index e3347241a..ca055fd98 100644 --- a/apps/api/src/box/dto/runner-health.dto.ts +++ b/apps/api/src/box/dto/runner-health.dto.ts @@ -159,4 +159,14 @@ export class RunnerHealthcheckDto { }) @IsString() appVersion: string + + @ApiPropertyOptional({ + description: 'Optional runner features used for rollout negotiation', + type: [String], + example: ['linux-capabilities-v2'], + }) + @IsOptional() + @IsArray() + @IsString({ each: true }) + features?: string[] } diff --git a/apps/api/src/box/entities/box.entity.ts b/apps/api/src/box/entities/box.entity.ts index a4eb59fbf..f4377e0b2 100644 --- a/apps/api/src/box/entities/box.entity.ts +++ b/apps/api/src/box/entities/box.entity.ts @@ -17,6 +17,7 @@ import { DEFAULT_AUTO_PAUSE_SECONDS, DEFAULT_AUTO_RESUME, } from '../constants/box-lifecycle.constants' +import { BoxAdvancedOptions, normalizeBoxAdvancedOptions } from '../common/box-advanced-options' @Entity('box') @Unique(['organizationId', 'name']) @@ -108,6 +109,12 @@ export class Box { }) env: { [key: string]: string } = {} + @Column({ + type: 'jsonb', + default: () => `'${JSON.stringify(normalizeBoxAdvancedOptions())}'::jsonb`, + }) + advanced: BoxAdvancedOptions = normalizeBoxAdvancedOptions() + @Column({ default: false, type: 'boolean' }) public = false diff --git a/apps/api/src/box/entities/runner.entity.ts b/apps/api/src/box/entities/runner.entity.ts index e1ba1253e..afe757824 100644 --- a/apps/api/src/box/entities/runner.entity.ts +++ b/apps/api/src/box/entities/runner.entity.ts @@ -145,6 +145,12 @@ export class Runner { }) apiVersion: string + @Column({ + type: 'jsonb', + default: [], + }) + features: string[] = [] + @Column({ nullable: true, type: 'timestamp with time zone', diff --git a/apps/api/src/box/enums/job-type.enum.ts b/apps/api/src/box/enums/job-type.enum.ts index a4a4e2767..ffee6e389 100644 --- a/apps/api/src/box/enums/job-type.enum.ts +++ b/apps/api/src/box/enums/job-type.enum.ts @@ -6,6 +6,7 @@ export enum JobType { CREATE_BOX = 'CREATE_BOX', + CREATE_BOX_WITH_CAPABILITIES_V2 = 'CREATE_BOX_WITH_CAPABILITIES_V2', START_BOX = 'START_BOX', STOP_BOX = 'STOP_BOX', DESTROY_BOX = 'DESTROY_BOX', @@ -13,6 +14,7 @@ export enum JobType { CREATE_BACKUP = 'CREATE_BACKUP', PULL_ARTIFACT = 'PULL_ARTIFACT', RECOVER_BOX = 'RECOVER_BOX', + RECOVER_BOX_WITH_CAPABILITIES_V2 = 'RECOVER_BOX_WITH_CAPABILITIES_V2', INSPECT_ARTIFACT_IN_REGISTRY = 'INSPECT_ARTIFACT_IN_REGISTRY', REMOVE_ARTIFACT = 'REMOVE_ARTIFACT', UPDATE_BOX_NETWORK_SETTINGS = 'UPDATE_BOX_NETWORK_SETTINGS', diff --git a/apps/api/src/box/managers/box-actions/box-start.action.spec.ts b/apps/api/src/box/managers/box-actions/box-start.action.spec.ts index e0b112820..434e17cc7 100644 --- a/apps/api/src/box/managers/box-actions/box-start.action.spec.ts +++ b/apps/api/src/box/managers/box-actions/box-start.action.spec.ts @@ -10,7 +10,7 @@ jest.mock('uuid', () => ({ })) import { BoxStartAction } from './box-start.action' -import { BoxAction, SYNC_AGAIN } from './box.action' +import { BoxAction, DONT_SYNC_AGAIN, SYNC_AGAIN } from './box.action' import { Box } from '../../entities/box.entity' import { Runner } from '../../entities/runner.entity' import { BoxState } from '../../enums/box-state.enum' @@ -30,10 +30,10 @@ describe('BoxStartAction.handleRunnerBoxStoppedStateOnDesiredStateStart', () => const ownRunner = { id: ownRunnerId, state: RunnerState.READY } as Runner - // findOneOrFail must return the runner that matches the requested id so we can + // findOneCurrentOrFail must return the runner that matches the requested id so we can // prove the action selected box.runnerId and nothing else. const runnerService = { - findOneOrFail: jest.fn(async (id: string) => { + findOneCurrentOrFail: jest.fn(async (id: string) => { if (id !== ownRunnerId) { throw new Error(`unexpected runner lookup: ${id}`) } @@ -81,8 +81,8 @@ describe('BoxStartAction.handleRunnerBoxStoppedStateOnDesiredStateStart', () => // The action started the box on its OWN runner, not a different one. expect(runnerUsedForStart?.id).toBe(ownRunnerId) expect(startBox).toHaveBeenCalledWith(box.id, box.authToken, expect.any(Object)) - // findOneOrFail was only ever asked about the box's own runner. - for (const call of runnerService.findOneOrFail.mock.calls) { + // The current-state lookup was only ever asked about the box's own runner. + for (const call of runnerService.findOneCurrentOrFail.mock.calls) { expect(call[0]).toBe(ownRunnerId) } expect(result).toBe(SYNC_AGAIN) @@ -96,7 +96,7 @@ describe('BoxStartAction.handleRunnerBoxStoppedStateOnDesiredStateStart', () => box.desiredState = BoxDesiredState.STARTED box.pending = true - const runnerService = { findOneOrFail: jest.fn() } + const runnerService = { findOneCurrentOrFail: jest.fn() } const runnerAdapterFactory = { create: jest.fn() } const lockCode = new LockCode('lock-2') const updatedFields: Partial[] = [] @@ -122,10 +122,55 @@ describe('BoxStartAction.handleRunnerBoxStoppedStateOnDesiredStateStart', () => await (action as BoxAction).run(box, lockCode) // No runner lookup or adapter creation: there is no runner to recover onto. - expect(runnerService.findOneOrFail).not.toHaveBeenCalled() + expect(runnerService.findOneCurrentOrFail).not.toHaveBeenCalled() expect(runnerAdapterFactory.create).not.toHaveBeenCalled() expect(updatedFields.some((u) => u.state === BoxState.ERROR)).toBe(true) }) + + it('rejects a capability policy when its assigned runner does not advertise support', async () => { + const runnerId = 'runner-without-capabilities' + const box = new Box('region-1', 'stopped-capability-box') + box.runnerId = runnerId + box.state = BoxState.STOPPED + box.desiredState = BoxDesiredState.STARTED + box.pending = true + box.advanced = { capabilities: { add: [], drop: ['NET_RAW'] } } + + const runner = { id: runnerId, state: RunnerState.READY, features: [] } as Runner + const runnerService = { findOneCurrentOrFail: jest.fn(async () => runner) } + const runnerAdapterFactory = { create: jest.fn() } + const lockCode = new LockCode('lock-capability-restart') + const updatedFields: Partial[] = [] + const boxRepository = { + update: jest.fn(async (_id: string, opts: { updateData: Partial }) => { + updatedFields.push(opts.updateData) + return box + }), + } + const redisLockProvider = { getCode: jest.fn(async () => lockCode) } + const organizationService = { findOne: jest.fn(async () => ({ boxMetadata: {} })) } + + const action = new BoxStartAction( + runnerService as any, + runnerAdapterFactory as any, + boxRepository as any, + organizationService as any, + {} as any, + redisLockProvider as any, + {} as any, + ) + + const result = await (action as BoxAction).run(box, lockCode) + + expect(result).toBe(DONT_SYNC_AGAIN) + expect(runnerAdapterFactory.create).not.toHaveBeenCalled() + expect(updatedFields).toContainEqual( + expect.objectContaining({ + state: BoxState.ERROR, + errorReason: expect.stringContaining('linux-capabilities-v2'), + }), + ) + }) }) describe('BoxStartAction.handleRunnerBoxUnknownStateOnDesiredStateStart', () => { @@ -140,7 +185,7 @@ describe('BoxStartAction.handleRunnerBoxUnknownStateOnDesiredStateStart', () => box.pending = true const runner = { id: runnerId, state: RunnerState.READY } as Runner - const runnerService = { findOneOrFail: jest.fn(async () => runner) } + const runnerService = { findOneCurrentOrFail: jest.fn(async () => runner) } const createBox = jest.fn(async () => undefined) const runnerAdapterFactory = { create: jest.fn(async () => ({ createBox }) as any) } @@ -183,7 +228,7 @@ describe('BoxStartAction.handleRunnerBoxUnknownStateOnDesiredStateStart', () => box.pending = true const runner = { id: runnerId, state: RunnerState.READY } as Runner - const runnerService = { findOneOrFail: jest.fn(async () => runner) } + const runnerService = { findOneCurrentOrFail: jest.fn(async () => runner) } const createBox = jest.fn(async () => undefined) const runnerAdapterFactory = { create: jest.fn(async () => ({ createBox }) as any) } @@ -214,4 +259,50 @@ describe('BoxStartAction.handleRunnerBoxUnknownStateOnDesiredStateStart', () => expect(createBox).not.toHaveBeenCalled() expect(updatedFields.some((u) => u.state === BoxState.ERROR)).toBe(true) }) + + it('rejects a capability policy when its assigned runner does not advertise support', async () => { + const runnerId = 'runner-without-capabilities' + const box = new Box('region-1', 'new-capability-box') + box.runnerId = runnerId + box.image = 'boxlite/base' + box.state = BoxState.UNKNOWN + box.desiredState = BoxDesiredState.STARTED + box.pending = true + box.advanced = { capabilities: { add: ['SYS_PTRACE'], drop: [] } } + + const runner = { id: runnerId, state: RunnerState.READY, features: [] } as Runner + const runnerService = { findOneCurrentOrFail: jest.fn(async () => runner) } + const runnerAdapterFactory = { create: jest.fn() } + const lockCode = new LockCode('lock-capability-create') + const updatedFields: Partial[] = [] + const boxRepository = { + update: jest.fn(async (_id: string, opts: { updateData: Partial }) => { + updatedFields.push(opts.updateData) + return box + }), + } + const redisLockProvider = { getCode: jest.fn(async () => lockCode) } + const organizationService = { findOne: jest.fn(async () => ({ boxMetadata: {} })) } + + const action = new BoxStartAction( + runnerService as any, + runnerAdapterFactory as any, + boxRepository as any, + organizationService as any, + {} as any, + redisLockProvider as any, + {} as any, + ) + + const result = await (action as BoxAction).run(box, lockCode) + + expect(result).toBe(DONT_SYNC_AGAIN) + expect(runnerAdapterFactory.create).not.toHaveBeenCalled() + expect(updatedFields).toContainEqual( + expect.objectContaining({ + state: BoxState.ERROR, + errorReason: expect.stringContaining('linux-capabilities-v2'), + }), + ) + }) }) diff --git a/apps/api/src/box/managers/box-actions/box-start.action.ts b/apps/api/src/box/managers/box-actions/box-start.action.ts index 414de0c97..c0b2807c6 100644 --- a/apps/api/src/box/managers/box-actions/box-start.action.ts +++ b/apps/api/src/box/managers/box-actions/box-start.action.ts @@ -17,6 +17,8 @@ import { TypedConfigService } from '../../../config/typed-config.service' import { LockCode, RedisLockProvider } from '../../common/redis-lock.provider' import { WithSpan } from '../../../common/decorators/otel.decorator' import { BoxActivityService } from '../../services/box-activity.service' +import { Runner } from '../../entities/runner.entity' +import { requiredRunnerFeaturesForCapabilities, runnerSupportsFeatures } from '../../constants/runner-features' @Injectable() export class BoxStartAction extends BoxAction { @@ -57,11 +59,15 @@ export class BoxStartAction extends BoxAction { } private async handleRunnerBoxUnknownStateOnDesiredStateStart(box: Box, lockCode: LockCode): Promise { - const runner = await this.runnerService.findOneOrFail(box.runnerId) + const runner = await this.runnerService.findOneCurrentOrFail(box.runnerId) if (runner.state !== RunnerState.READY) { return DONT_SYNC_AGAIN } + if (!(await this.ensureRunnerSupportsCapabilities(box, runner, lockCode))) { + return DONT_SYNC_AGAIN + } + if (!box.image) { await this.updateBoxState(box, BoxState.ERROR, lockCode, undefined, 'Box has no image to create from') return DONT_SYNC_AGAIN @@ -92,12 +98,16 @@ export class BoxStartAction extends BoxAction { return DONT_SYNC_AGAIN } - const runner = await this.runnerService.findOneOrFail(box.runnerId) + const runner = await this.runnerService.findOneCurrentOrFail(box.runnerId) if (runner.state !== RunnerState.READY) { return DONT_SYNC_AGAIN } + if (!(await this.ensureRunnerSupportsCapabilities(box, runner, lockCode))) { + return DONT_SYNC_AGAIN + } + const runnerAdapter = await this.runnerAdapterFactory.create(runner) const metadata: { [key: string]: string } = { ...organization?.boxMetadata } @@ -191,6 +201,18 @@ export class BoxStartAction extends BoxAction { return SYNC_AGAIN } + private async ensureRunnerSupportsCapabilities(box: Box, runner: Runner, lockCode: LockCode): Promise { + const requiredFeatures = requiredRunnerFeaturesForCapabilities(box.advanced.capabilities) + if (runnerSupportsFeatures(runner.features, requiredFeatures)) { + return true + } + + const errorReason = `Runner ${runner.id} does not support required feature: ${requiredFeatures.join(', ')}` + this.logger.error(`Cannot start box ${box.id}: ${errorReason}`) + await this.updateBoxState(box, BoxState.ERROR, lockCode, undefined, errorReason) + return false + } + private async checkTimeoutError(box: Box, timeoutMinutes: number, errorReason: string): Promise { const lastActivityAt = await this.boxActivityService.getLastActivityAt(box.id) if (lastActivityAt && lastActivityAt.getTime() < Date.now() - 1000 * 60 * timeoutMinutes) { diff --git a/apps/api/src/box/runner-adapter/runnerAdapter.ts b/apps/api/src/box/runner-adapter/runnerAdapter.ts index 1d1d023f0..dff6a2e73 100644 --- a/apps/api/src/box/runner-adapter/runnerAdapter.ts +++ b/apps/api/src/box/runner-adapter/runnerAdapter.ts @@ -32,6 +32,7 @@ export interface RunnerInfo { serviceHealth?: RunnerServiceInfo[] metrics?: RunnerMetrics appVersion?: string + features?: string[] } export interface StartBoxResponse { diff --git a/apps/api/src/box/runner-adapter/runnerAdapter.v0.spec.ts b/apps/api/src/box/runner-adapter/runnerAdapter.v0.spec.ts new file mode 100644 index 000000000..e23be954d --- /dev/null +++ b/apps/api/src/box/runner-adapter/runnerAdapter.v0.spec.ts @@ -0,0 +1,87 @@ +/* + * Copyright 2025 BoxLite AI + * SPDX-License-Identifier: AGPL-3.0 + */ + +import { Box } from '../entities/box.entity' +import { RunnerAdapterV0 } from './runnerAdapter.v0' + +describe('RunnerAdapterV0 capability propagation', () => { + function makeAdapter() { + const boxApiClient = { + create: jest.fn().mockResolvedValue({ data: {} }), + createWithCapabilities: jest.fn().mockResolvedValue({ data: {} }), + recover: jest.fn().mockResolvedValue({ data: {} }), + recoverWithCapabilities: jest.fn().mockResolvedValue({ data: {} }), + } + const adapter = new RunnerAdapterV0() + Object.assign(adapter as any, { boxApiClient }) + return { adapter, boxApiClient } + } + + function customCapabilityBox() { + const box = new Box('region-1', 'cap-box') + Object.assign(box, { + image: 'alpine:latest', + organizationId: 'org-1', + osUser: 'boxlite', + advanced: { capabilities: { add: ['SYS_ADMIN'], drop: ['NET_RAW'] } }, + }) + return box + } + + it('uses the strict create contract for capability overrides', async () => { + const { adapter, boxApiClient } = makeAdapter() + + await adapter.createBox(customCapabilityBox()) + + expect(boxApiClient.createWithCapabilities).toHaveBeenCalledWith( + expect.objectContaining({ + advanced: { capabilities: { add: ['SYS_ADMIN'], drop: ['NET_RAW'] } }, + }), + ) + expect(boxApiClient.create).not.toHaveBeenCalled() + }) + + it('uses the strict recovery contract for capability overrides', async () => { + const { adapter, boxApiClient } = makeAdapter() + const box = customCapabilityBox() + + await adapter.recoverBox(box) + + expect(boxApiClient.recoverWithCapabilities).toHaveBeenCalledWith( + box.id, + expect.objectContaining({ + advanced: { capabilities: { add: ['SYS_ADMIN'], drop: ['NET_RAW'] } }, + }), + ) + expect(boxApiClient.recover).not.toHaveBeenCalled() + }) + + it('keeps capability-free creates on the legacy contract', async () => { + const { adapter, boxApiClient } = makeAdapter() + const box = customCapabilityBox() + box.advanced = { capabilities: { add: [], drop: [] } } + + await adapter.createBox(box) + + expect(boxApiClient.create).toHaveBeenCalledWith( + expect.not.objectContaining({ advanced: expect.anything() }), + ) + expect(boxApiClient.createWithCapabilities).not.toHaveBeenCalled() + }) + + it('keeps capability-free recovery on the legacy contract', async () => { + const { adapter, boxApiClient } = makeAdapter() + const box = customCapabilityBox() + box.advanced = { capabilities: { add: [], drop: [] } } + + await adapter.recoverBox(box) + + expect(boxApiClient.recover).toHaveBeenCalledWith( + box.id, + expect.not.objectContaining({ advanced: expect.anything() }), + ) + expect(boxApiClient.recoverWithCapabilities).not.toHaveBeenCalled() + }) +}) diff --git a/apps/api/src/box/runner-adapter/runnerAdapter.v0.ts b/apps/api/src/box/runner-adapter/runnerAdapter.v0.ts index dd6e55585..e4da3dcce 100644 --- a/apps/api/src/box/runner-adapter/runnerAdapter.v0.ts +++ b/apps/api/src/box/runner-adapter/runnerAdapter.v0.ts @@ -19,6 +19,7 @@ import { UpdateNetworkSettingsDTO, RecoverBoxDTO, } from '@boxlite-ai/runner-api-client' +import { hasCapabilityPolicy, normalizeBoxAdvancedOptions } from '../common/box-advanced-options' import { Box } from '../entities/box.entity' import { BoxState } from '../enums/box-state.enum' import { RunnerApiError } from '../errors/runner-api-error' @@ -238,6 +239,7 @@ export class RunnerAdapterV0 implements RunnerAdapter { serviceHealth: response.data.serviceHealth, metrics: response.data.metrics, appVersion: response.data.appVersion, + features: response.data.features, } } @@ -250,7 +252,7 @@ export class RunnerAdapterV0 implements RunnerAdapter { } async createBox(box: Box, metadata?: { [key: string]: string }): Promise { - const response = await this.boxApiClient.create({ + const createBoxDTO = { id: box.id, image: box.image ?? '', osUser: box.osUser, @@ -265,7 +267,14 @@ export class RunnerAdapterV0 implements RunnerAdapter { authToken: box.authToken, organizationId: box.organizationId, regionId: box.region, - }) + } + const advanced = normalizeBoxAdvancedOptions(box.advanced) + const response = hasCapabilityPolicy(advanced) + ? await this.boxApiClient.createWithCapabilities({ + ...createBoxDTO, + advanced, + }) + : await this.boxApiClient.create(createBoxDTO) if (!response?.data?.daemonVersion) { return undefined @@ -332,6 +341,14 @@ export class RunnerAdapterV0 implements RunnerAdapter { networkAllowList: box.networkAllowList, errorReason: box.errorReason, } + const advanced = normalizeBoxAdvancedOptions(box.advanced) + if (hasCapabilityPolicy(advanced)) { + await this.boxApiClient.recoverWithCapabilities(box.id, { + ...recoverBoxDTO, + advanced, + }) + return + } await this.boxApiClient.recover(box.id, recoverBoxDTO) } } diff --git a/apps/api/src/box/runner-adapter/runnerAdapter.v2.spec.ts b/apps/api/src/box/runner-adapter/runnerAdapter.v2.spec.ts new file mode 100644 index 000000000..da2bcf26a --- /dev/null +++ b/apps/api/src/box/runner-adapter/runnerAdapter.v2.spec.ts @@ -0,0 +1,99 @@ +/* + * Copyright 2025 BoxLite AI + * SPDX-License-Identifier: AGPL-3.0 + */ + +import { Box } from '../entities/box.entity' +import { JobType } from '../enums/job-type.enum' +import { ResourceType } from '../enums/resource-type.enum' +import { RunnerAdapterV2 } from './runnerAdapter.v2' + +describe('RunnerAdapterV2 capability propagation', () => { + function makeAdapter() { + const jobService = { createJob: jest.fn().mockResolvedValue(undefined) } + const adapter = new RunnerAdapterV2({} as any, {} as any, jobService as any) + adapter.init({ id: 'runner-1' } as any) + return { adapter, jobService } + } + + function customCapabilityBox() { + const box = new Box('region-1', 'cap-box') + Object.assign(box as any, { + image: 'alpine:latest', + organizationId: 'org-1', + osUser: 'boxlite', + advanced: { capabilities: { add: ['SYS_ADMIN'], drop: ['NET_RAW'] } }, + }) + return box + } + + it('uses a distinct create job type for capability overrides', async () => { + const { adapter, jobService } = makeAdapter() + const box = customCapabilityBox() + + await adapter.createBox(box) + + expect(jobService.createJob).toHaveBeenCalledWith( + null, + JobType.CREATE_BOX_WITH_CAPABILITIES_V2, + 'runner-1', + ResourceType.BOX, + box.id, + expect.objectContaining({ + advanced: { capabilities: { add: ['SYS_ADMIN'], drop: ['NET_RAW'] } }, + }), + ) + }) + + it('uses a distinct recovery job type for capability overrides', async () => { + const { adapter, jobService } = makeAdapter() + const box = customCapabilityBox() + + await adapter.recoverBox(box) + + expect(jobService.createJob).toHaveBeenCalledWith( + null, + JobType.RECOVER_BOX_WITH_CAPABILITIES_V2, + 'runner-1', + ResourceType.BOX, + box.id, + expect.objectContaining({ + advanced: { capabilities: { add: ['SYS_ADMIN'], drop: ['NET_RAW'] } }, + }), + ) + }) + + it('keeps capability-free creates on the legacy job type', async () => { + const { adapter, jobService } = makeAdapter() + const box = customCapabilityBox() + box.advanced = { capabilities: { add: [], drop: [] } } + + await adapter.createBox(box) + + expect(jobService.createJob).toHaveBeenCalledWith( + null, + JobType.CREATE_BOX, + 'runner-1', + ResourceType.BOX, + box.id, + expect.not.objectContaining({ advanced: expect.anything() }), + ) + }) + + it('keeps capability-free recovery on the legacy job type', async () => { + const { adapter, jobService } = makeAdapter() + const box = customCapabilityBox() + box.advanced = { capabilities: { add: [], drop: [] } } + + await adapter.recoverBox(box) + + expect(jobService.createJob).toHaveBeenCalledWith( + null, + JobType.RECOVER_BOX, + 'runner-1', + ResourceType.BOX, + box.id, + expect.not.objectContaining({ advanced: expect.anything() }), + ) + }) +}) diff --git a/apps/api/src/box/runner-adapter/runnerAdapter.v2.ts b/apps/api/src/box/runner-adapter/runnerAdapter.v2.ts index ba4eee098..a178a773a 100644 --- a/apps/api/src/box/runner-adapter/runnerAdapter.v2.ts +++ b/apps/api/src/box/runner-adapter/runnerAdapter.v2.ts @@ -13,6 +13,7 @@ import { Box } from '../entities/box.entity' import { Job } from '../entities/job.entity' import { BoxState } from '../enums/box-state.enum' import { JobType } from '../enums/job-type.enum' +import { hasCapabilityPolicy, normalizeBoxAdvancedOptions } from '../common/box-advanced-options' import { JobStatus } from '../enums/job-status.enum' import { ResourceType } from '../enums/resource-type.enum' import { JobService } from '../services/job.service' @@ -101,6 +102,7 @@ export class RunnerAdapterV2 implements RunnerAdapter { // Map job types to transitional states switch (job.type) { case JobType.CREATE_BOX: + case JobType.CREATE_BOX_WITH_CAPABILITIES_V2: return job.status === JobStatus.COMPLETED ? BoxState.STARTED : BoxState.CREATING case JobType.START_BOX: return job.status === JobStatus.COMPLETED ? BoxState.STARTED : BoxState.STARTING @@ -141,9 +143,14 @@ export class RunnerAdapterV2 implements RunnerAdapter { regionId: box.region, } - await this.jobService.createJob(null, JobType.CREATE_BOX, this.runner.id, ResourceType.BOX, box.id, payload) + const advanced = normalizeBoxAdvancedOptions(box.advanced) + const hasCustomCapabilities = hasCapabilityPolicy(advanced) + const jobType = hasCustomCapabilities ? JobType.CREATE_BOX_WITH_CAPABILITIES_V2 : JobType.CREATE_BOX + const jobPayload = hasCustomCapabilities ? { ...payload, advanced } : payload - this.logger.debug(`Created CREATE_BOX job for box ${box.id} on runner ${this.runner.id}`) + await this.jobService.createJob(null, jobType, this.runner.id, ResourceType.BOX, box.id, jobPayload) + + this.logger.debug(`Created ${jobType} job for box ${box.id} on runner ${this.runner.id}`) // Daemon version is set in the job result metadata once the runner completes the job. return undefined @@ -196,9 +203,14 @@ export class RunnerAdapterV2 implements RunnerAdapter { networkAllowList: box.networkAllowList, errorReason: box.errorReason, } - await this.jobService.createJob(null, JobType.RECOVER_BOX, this.runner.id, ResourceType.BOX, box.id, recoverBoxDTO) + const advanced = normalizeBoxAdvancedOptions(box.advanced) + const hasCustomCapabilities = hasCapabilityPolicy(advanced) + const jobType = hasCustomCapabilities ? JobType.RECOVER_BOX_WITH_CAPABILITIES_V2 : JobType.RECOVER_BOX + const jobPayload = hasCustomCapabilities ? { ...recoverBoxDTO, advanced } : recoverBoxDTO + + await this.jobService.createJob(null, jobType, this.runner.id, ResourceType.BOX, box.id, jobPayload) - this.logger.debug(`Created RECOVER_BOX job for box ${box.id} on runner ${this.runner.id}`) + this.logger.debug(`Created ${jobType} job for box ${box.id} on runner ${this.runner.id}`) } async updateNetworkSettings( diff --git a/apps/api/src/box/services/box.service.spec.ts b/apps/api/src/box/services/box.service.spec.ts index 3df691be6..beac32535 100644 --- a/apps/api/src/box/services/box.service.spec.ts +++ b/apps/api/src/box/services/box.service.spec.ts @@ -256,18 +256,21 @@ describe('BoxService network tunnel URLs', () => { describe('BoxService public defaults', () => { function makeCreateService() { const boxRepository = { insert: jest.fn(async (box: any) => box) } as any + const runnerService = { + getRandomAvailableRunner: jest.fn().mockResolvedValue({ id: 'runner-1', features: ['linux-capabilities-v2'] }), + } const service = Object.create(BoxService.prototype) as BoxService Object.assign(service as any, { getValidatedOrDefaultRegion: jest.fn().mockResolvedValue({ id: 'region-1' }), getValidatedOrDefaultClass: jest.fn().mockReturnValue('small'), organizationService: { assertOrganizationIsNotSuspended: jest.fn() }, redis: { exists: jest.fn().mockResolvedValue(1) }, - runnerService: { getRandomAvailableRunner: jest.fn().mockResolvedValue({ id: 'runner-1' }) }, + runnerService, boxRepository, eventEmitter: { emitAsync: jest.fn().mockResolvedValue(undefined) }, toBoxDto: jest.fn((box) => box), }) - return { service, boxRepository } + return { service, boxRepository, runnerService } } it.each([ @@ -281,6 +284,57 @@ describe('BoxService public defaults', () => { expect(boxRepository.insert).toHaveBeenCalledWith(expect.objectContaining({ public: expectedPublic })) }) + it('persists capability overrides on a fresh box', async () => { + const { service, boxRepository, runnerService } = makeCreateService() + + await service.create( + { + name: 'cap-box', + advanced: { capabilities: { add: ['SYS_ADMIN'], drop: ['NET_RAW'] } }, + } as any, + { id: 'org-1' } as any, + ) + + expect(boxRepository.insert).toHaveBeenCalledWith( + expect.objectContaining({ + advanced: { capabilities: { add: ['SYS_ADMIN'], drop: ['NET_RAW'] } }, + }), + ) + expect(runnerService.getRandomAvailableRunner).toHaveBeenCalledWith( + expect.objectContaining({ requiredFeatures: ['linux-capabilities-v2'] }), + ) + }) + + it('does not assign a default-capability warm-pool box to a custom-capability request', async () => { + const fetchWarmPoolBox = jest.fn().mockResolvedValue(null) + const boxRepository = { insert: jest.fn(async (box: any) => box) } as any + const service = Object.create(BoxService.prototype) as BoxService + Object.assign(service as any, { + getValidatedOrDefaultRegion: jest.fn().mockResolvedValue({ id: 'region-1' }), + getValidatedOrDefaultClass: jest.fn().mockReturnValue('small'), + organizationService: { assertOrganizationIsNotSuspended: jest.fn() }, + redis: { exists: jest.fn().mockResolvedValue(0) }, + warmPoolService: { fetchWarmPoolBox }, + runnerService: { + getRandomAvailableRunner: jest.fn().mockResolvedValue({ id: 'runner-1', features: ['linux-capabilities-v2'] }), + }, + boxRepository, + eventEmitter: { emitAsync: jest.fn().mockResolvedValue(undefined) }, + toBoxDto: jest.fn((box) => box), + }) + + await service.create( + { + name: 'cap-box', + image: 'base', + advanced: { capabilities: { add: ['SYS_ADMIN'], drop: [] } }, + } as any, + { id: 'org-1' } as any, + ) + + expect(fetchWarmPoolBox).not.toHaveBeenCalled() + }) + it.each([ [undefined, true], [false, false], diff --git a/apps/api/src/box/services/box.service.ts b/apps/api/src/box/services/box.service.ts index 524c84c09..e44eda7f6 100644 --- a/apps/api/src/box/services/box.service.ts +++ b/apps/api/src/box/services/box.service.ts @@ -38,6 +38,8 @@ import { TypedConfigService } from '../../config/typed-config.service' import { WarmPool } from '../entities/warm-pool.entity' import { BoxDto, BoxVolume } from '../dto/box.dto' import { RunnerAdapterFactory } from '../runner-adapter/runnerAdapter' +import { requiredRunnerFeaturesForCapabilities, runnerSupportsFeatures } from '../constants/runner-features' +import { normalizeBoxAdvancedOptions } from '../common/box-advanced-options' import { validateNetworkAllowList } from '../utils/network-validation.util' import { SshAccess } from '../entities/ssh-access.entity' import { SshAccessDto, SshAccessValidationDto } from '../dto/ssh-access.dto' @@ -172,13 +174,16 @@ export class BoxService { // Restrict box creation to the supported pinned images; reject anything else // at the request boundary (defaults undefined -> base image). const image = assertSupportedImage(createBoxDto.image) + const advanced = normalizeBoxAdvancedOptions(createBoxDto.advanced) + const requiredRunnerFeatures = requiredRunnerFeaturesForCapabilities(advanced.capabilities) + const hasCustomCapabilities = requiredRunnerFeatures.length > 0 this.organizationService.assertOrganizationIsNotSuspended(organization) if (createBoxDto.volumes && createBoxDto.volumes.length > 0) { const volumeIdOrNames = createBoxDto.volumes.map((v) => v.volumeId) await this.volumeService.validateVolumes(organization.id, volumeIdOrNames) - } else if (image) { + } else if (image && !hasCustomCapabilities) { // No volumes requested — try to claim a pre-warmed box matching this image/spec // before creating a fresh one. const skipWarmPool = (await this.redis.exists(`warm-pool:skip:${image}`)) === 1 @@ -206,7 +211,13 @@ export class BoxService { const runner = await this.runnerService.getRandomAvailableRunner({ regions: [region.id], boxClass, + requiredFeatures: hasCustomCapabilities ? requiredRunnerFeatures : undefined, }) + if (!runnerSupportsFeatures(runner.features, requiredRunnerFeatures)) { + throw new BadRequestError( + `Runner ${runner.id} does not support required feature: ${requiredRunnerFeatures.join(', ')}`, + ) + } const box = new Box(region.id, createBoxDto.name) @@ -217,6 +228,7 @@ export class BoxService { // TODO: default user should be configurable box.osUser = createBoxDto.user || 'boxlite' box.env = createBoxDto.env || {} + box.advanced = advanced box.labels = createBoxDto.labels || {} box.image = image @@ -929,7 +941,14 @@ export class BoxService { if (!box.runnerId) { throw new NotFoundException(`Box with ID ${box.id} does not have a runner`) } - const runner = await this.runnerService.findOneOrFail(box.runnerId) + const runner = await this.runnerService.findOneCurrentOrFail(box.runnerId) + + const requiredRunnerFeatures = requiredRunnerFeaturesForCapabilities(box.advanced.capabilities) + if (!runnerSupportsFeatures(runner.features, requiredRunnerFeatures)) { + throw new BadRequestError( + `Runner ${runner.id} does not support required feature: ${requiredRunnerFeatures.join(', ')}`, + ) + } if (runner.apiVersion === '2') { // TODO: we need "recovering" state that can be set after calling recover diff --git a/apps/api/src/box/services/job-state-handler.service.ts b/apps/api/src/box/services/job-state-handler.service.ts index 8ae333836..37e544802 100644 --- a/apps/api/src/box/services/job-state-handler.service.ts +++ b/apps/api/src/box/services/job-state-handler.service.ts @@ -45,6 +45,7 @@ export class JobStateHandlerService { switch (job.type) { case JobType.CREATE_BOX: + case JobType.CREATE_BOX_WITH_CAPABILITIES_V2: await this.handleCreateBoxJobCompletion(job) break case JobType.START_BOX: @@ -62,6 +63,7 @@ export class JobStateHandlerService { // TODO(image-rewrite): PULL_IMAGE / REMOVE_IMAGE job handling removed with // the runner image subsystems; rebuild artifact lifecycle handling here. case JobType.RECOVER_BOX: + case JobType.RECOVER_BOX_WITH_CAPABILITIES_V2: await this.handleRecoverBoxJobCompletion(job) break default: diff --git a/apps/api/src/box/services/runner.service.ts b/apps/api/src/box/services/runner.service.ts index 650edc2c3..2ee9393ce 100644 --- a/apps/api/src/box/services/runner.service.ts +++ b/apps/api/src/box/services/runner.service.ts @@ -44,6 +44,7 @@ import { BoxDesiredState } from '../enums/box-desired-state.enum' import { runnerLookupCacheKeyById, RUNNER_LOOKUP_CACHE_TTL_MS } from '../utils/runner-lookup-cache.util' import { BoxRepository } from '../repositories/box.repository' import { RunnerServiceInfo } from '../common/runner-service-info' +import { runnerSupportsFeatures } from '../constants/runner-features' @Injectable() export class RunnerService { @@ -224,6 +225,15 @@ export class RunnerService { return runner } + /** Read security-sensitive runner state directly from the database. */ + async findOneCurrentOrFail(id: string): Promise { + const runner = await this.runnerRepository.findOne({ where: { id } }) + if (!runner) { + throw new NotFoundException(`Runner with ID ${id} not found`) + } + return runner + } + async findOneFullOrFail(id: string): Promise { const runner = await this.findOneOrFail(id) const region = await this.regionService.findOne(runner.region) @@ -312,7 +322,10 @@ export class RunnerService { where: runnerFilter, }) - return runners.sort((a, b) => b.availabilityScore - a.availabilityScore).slice(0, 10) + return runners + .filter((runner) => runnerSupportsFeatures(runner.features, params.requiredFeatures)) + .sort((a, b) => b.availabilityScore - a.availabilityScore) + .slice(0, 10) } /** @@ -370,6 +383,7 @@ export class RunnerService { diskGiB?: number }, appVersion?: string, + features?: string[], ): Promise { const runner = await this.findOne(runnerId) if (!runner) { @@ -403,6 +417,10 @@ export class RunnerService { updateData.appVersion = appVersion } + // Absence means an older runner. Clearing on every heartbeat prevents a + // downgraded runner from retaining capabilities it no longer advertises. + updateData.features = [...new Set(features ?? [])] + if (serviceHealth !== undefined) { updateData.serviceHealth = serviceHealth } else { @@ -554,6 +572,7 @@ export class RunnerService { runnerInfo?.serviceHealth, runnerInfo?.metrics, runnerInfo?.appVersion, + runnerInfo?.features, ) })(), new Promise((_, reject) => { @@ -723,7 +742,10 @@ export class RunnerService { const availableRunners = await this.findAvailableRunners(params) if (availableRunners.length === 0) { - throw new BadRequestError('No available runners') + const required = params.requiredFeatures?.join(', ') + throw new BadRequestError( + required ? `No available runners support required features: ${required}` : 'No available runners', + ) } // Get random runner from the best available runners @@ -914,6 +936,7 @@ export class GetRunnerParams { boxClass?: BoxClass excludedRunnerIds?: string[] availabilityScoreThreshold?: number + requiredFeatures?: string[] } interface AvailabilityScoreParams { diff --git a/apps/api/src/box/utils/capability-validation.util.ts b/apps/api/src/box/utils/capability-validation.util.ts new file mode 100644 index 000000000..2b6245074 --- /dev/null +++ b/apps/api/src/box/utils/capability-validation.util.ts @@ -0,0 +1,35 @@ +/* + * Copyright 2025 BoxLite AI + * SPDX-License-Identifier: AGPL-3.0 + */ + +import { ValidatorConstraint, ValidatorConstraintInterface } from 'class-validator' + +export function isValidLinuxCapabilityName(value: unknown): boolean { + if ( + typeof value !== 'string' || + value.length === 0 || + [...value].some((character) => character.charCodeAt(0) > 0x7f) + ) { + return false + } + + const normalized = value.toUpperCase() + if (normalized === 'ALL') { + return true + } + + const name = normalized.startsWith('CAP_') ? normalized.slice(4) : normalized + return /^[A-Z][A-Z0-9_]*$/.test(name) +} + +@ValidatorConstraint({ name: 'isLinuxCapabilityName', async: false }) +export class IsLinuxCapabilityNameConstraint implements ValidatorConstraintInterface { + validate(value: unknown): boolean { + return isValidLinuxCapabilityName(value) + } + + defaultMessage(): string { + return 'each capability must be a Linux capability name or ALL' + } +} diff --git a/apps/api/src/boxlite-rest/boxlite-box.controller.ts b/apps/api/src/boxlite-rest/boxlite-box.controller.ts index e50184b44..a2c0f1bbd 100644 --- a/apps/api/src/boxlite-rest/boxlite-box.controller.ts +++ b/apps/api/src/boxlite-rest/boxlite-box.controller.ts @@ -11,10 +11,13 @@ import { Delete, Head, Body, + BadRequestException, Param, Query, HttpCode, UseGuards, + UsePipes, + ValidationPipe, Logger, Res, } from '@nestjs/common' @@ -35,6 +38,9 @@ import { boxToBoxResponse, createBoxToCreateBox } from './mappers/box-to-box.map import { Audit, MASKED_AUDIT_VALUE, TypedRequest } from '../audit/decorators/audit.decorator' import { AuditAction } from '../audit/enums/audit-action.enum' import { AuditTarget } from '../audit/enums/audit-target.enum' + +const LEGACY_CAPABILITY_FIELDS = ['capAdd', 'capDrop', 'cap_add', 'cap_drop'] as const + // Spec-first surface: the contract is openapi/box.openapi.yaml, not the // generated product spec (which `:prefix` routes would render invalid). @ApiExcludeController() @@ -75,6 +81,7 @@ export class BoxliteBoxController { working_dir: req.body?.working_dir, entrypoint: req.body?.entrypoint, cmd: req.body?.cmd, + advanced: req.body?.advanced, detach: req.body?.detach, auto_pause: req.body?.auto_pause, auto_delete: req.body?.auto_delete, @@ -86,6 +93,17 @@ export class BoxliteBoxController { @AuthContext() authContext: OrganizationAuthContext, @Body() dto: CreateBoxDto, ): Promise { + const request = dto as CreateBoxDto & Record + const hasFlatCapabilityField = LEGACY_CAPABILITY_FIELDS.some((field) => + Object.prototype.hasOwnProperty.call(request, field), + ) + if (dto.advanced !== undefined || hasFlatCapabilityField) { + throw new BadRequestException('advanced options require POST /v1/boxes/strict') + } + return this.createBoxWithOptions(authContext, dto) + } + + private async createBoxWithOptions(authContext: OrganizationAuthContext, dto: CreateBoxDto): Promise { const organization = authContext.organization const createBoxDto = createBoxToCreateBox(dto) @@ -96,7 +114,53 @@ export class BoxliteBoxController { return boxToBoxResponse(box) } - @Get() + /** + * Fail-closed create route for options that older API builds may not know. + * Capability-aware clients use this path so a mixed-version deployment + * returns 404 on an old instance instead of silently stripping cap fields. + */ + @Post('strict') + @HttpCode(201) + @UsePipes(new ValidationPipe({ transform: true, whitelist: true, forbidNonWhitelisted: true })) + @ApiResponse({ + status: 201, + description: 'Box created with strict option handling', + type: BoxResponseDto, + }) + @Audit({ + action: AuditAction.CREATE, + targetType: AuditTarget.BOX, + targetIdFromResult: (result: BoxResponseDto) => result?.box_id, + requestMetadata: { + body: (req: TypedRequest) => ({ + name: req.body?.name, + image: req.body?.image, + user: req.body?.user, + env: req.body?.env + ? Object.fromEntries(Object.keys(req.body?.env).map((key) => [key, MASKED_AUDIT_VALUE])) + : undefined, + cpus: req.body?.cpus, + memory_mib: req.body?.memory_mib, + disk_size_gb: req.body?.disk_size_gb, + working_dir: req.body?.working_dir, + entrypoint: req.body?.entrypoint, + cmd: req.body?.cmd, + advanced: req.body?.advanced, + detach: req.body?.detach, + auto_pause: req.body?.auto_pause, + auto_delete: req.body?.auto_delete, + auto_resume: req.body?.auto_resume, + }), + }, + }) + async createBoxStrict( + @AuthContext() authContext: OrganizationAuthContext, + @Body() dto: CreateBoxDto, + ): Promise { + return this.createBoxWithOptions(authContext, dto) + } + + @Get(['', 'strict']) @ApiResponse({ status: 200, description: 'List boxes', @@ -113,7 +177,7 @@ export class BoxliteBoxController { } } - @Get(':boxId') + @Get([':boxId', ':boxId/strict']) @ApiResponse({ status: 200, description: 'Box details', diff --git a/apps/api/src/boxlite-rest/boxlite-config.controller.spec.ts b/apps/api/src/boxlite-rest/boxlite-config.controller.spec.ts new file mode 100644 index 000000000..e5d4d4936 --- /dev/null +++ b/apps/api/src/boxlite-rest/boxlite-config.controller.spec.ts @@ -0,0 +1,15 @@ +/* + * Copyright 2025 Daytona Platforms Inc. + * Modified by BoxLite AI, 2025-2026 + * SPDX-License-Identifier: AGPL-3.0 + */ + +import { BoxliteConfigController } from './boxlite-config.controller' + +describe('BoxliteConfigController', () => { + it('advertises Linux capability policy support', () => { + const config = new BoxliteConfigController().getConfig() + + expect(config.capabilities.linux_capabilities_enabled).toBe(true) + }) +}) diff --git a/apps/api/src/boxlite-rest/boxlite-config.controller.ts b/apps/api/src/boxlite-rest/boxlite-config.controller.ts index 96e8a030f..a6017affd 100644 --- a/apps/api/src/boxlite-rest/boxlite-config.controller.ts +++ b/apps/api/src/boxlite-rest/boxlite-config.controller.ts @@ -16,6 +16,7 @@ export class BoxliteConfigController { getConfig() { return { capabilities: { + linux_capabilities_enabled: true, snapshots_enabled: false, clone_enabled: false, export_enabled: false, diff --git a/apps/api/src/boxlite-rest/boxlite-rest-routing.spec.ts b/apps/api/src/boxlite-rest/boxlite-rest-routing.spec.ts index fbafa67d2..bfb6daa4d 100644 --- a/apps/api/src/boxlite-rest/boxlite-rest-routing.spec.ts +++ b/apps/api/src/boxlite-rest/boxlite-rest-routing.spec.ts @@ -12,6 +12,7 @@ import { CombinedAuthGuard } from '../auth/combined-auth.guard' import { OrganizationResourceActionGuard } from '../organization/guards/organization-resource-action.guard' import { BoxService } from '../box/services/box.service' import { BoxStateWaiterService } from '../box/services/box-state-waiter.service' +import { BoxState } from '../box/enums/box-state.enum' import { BoxliteBoxController } from './boxlite-box.controller' import { BoxliteProxyController } from './boxlite-proxy.controller' import { BoxliteWsProxyService } from './boxlite-ws-proxy.service' @@ -37,6 +38,14 @@ describe('BoxLite REST routing', () => { useValue: { findAllDeprecated: jest.fn().mockResolvedValue([]), toBoxDtos: jest.fn().mockResolvedValue([]), + findOneByIdOrName: jest.fn().mockResolvedValue({ id: 'box-1' }), + toBoxDto: jest.fn().mockResolvedValue({ + id: 'box-1', + name: 'named', + state: BoxState.STARTED, + labels: {}, + advanced: { capabilities: { add: [], drop: [] } }, + }), }, }, { @@ -69,6 +78,15 @@ describe('BoxLite REST routing', () => { return fetch(`http://127.0.0.1:${address.port}${path}`) } + async function post(path: string, body: unknown): Promise { + const address = app.getHttpServer().address() as AddressInfo + return fetch(`http://127.0.0.1:${address.port}${path}`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify(body), + }) + } + afterEach(async () => { await app?.close() }) @@ -90,6 +108,95 @@ describe('BoxLite REST routing', () => { expect(await legacy.json()).toEqual({ boxes: [] }) }) + it('registers the strict policy-aware box read route', async () => { + await startRoutingTestApp() + + const canonical = await get('/api/v1/boxes/named/strict') + const prefixed = await get('/api/v1/default/boxes/named/strict') + + expect(canonical.status).toBe(200) + expect(await canonical.json()).toMatchObject({ + box_id: 'box-1', + advanced: { capabilities: { add: [], drop: [] } }, + }) + expect(prefixed.status).toBe(200) + }) + + it('registers the strict policy-aware box list route', async () => { + await startRoutingTestApp() + + const canonical = await get('/api/v1/boxes/strict') + const prefixed = await get('/api/v1/default/boxes/strict') + + expect(canonical.status).toBe(200) + expect(await canonical.json()).toEqual({ boxes: [] }) + expect(prefixed.status).toBe(200) + expect(await prefixed.json()).toEqual({ boxes: [] }) + }) + + it('rejects unknown fields at the strict create boundary', async () => { + await startRoutingTestApp() + + const response = await post('/api/v1/boxes/strict', { + image: 'alpine:latest', + advanced: { + capabilities: { + drop: ['NET_RAW'], + future_security_option: true, + }, + }, + }) + + expect(response.status).toBe(400) + }) + + it('rejects explicit null throughout the strict advanced capability path', async () => { + await startRoutingTestApp() + + const payloads = [ + { advanced: null }, + { advanced: { capabilities: null } }, + { advanced: { capabilities: { add: null } } }, + { advanced: { capabilities: { drop: null } } }, + ] + + for (const payload of payloads) { + const response = await post('/api/v1/boxes/strict', { + image: 'alpine:latest', + ...payload, + }) + expect(response.status).toBe(400) + } + }) + + it.each([null, {}, { capabilities: { add: [], drop: [] } }])( + 'rejects any advanced key at the legacy create boundary', + async (advanced) => { + await startRoutingTestApp() + + const response = await post('/api/v1/boxes', { + image: 'alpine:latest', + advanced, + }) + + expect(response.status).toBe(400) + }, + ) + + it.each(['capAdd', 'capDrop', 'cap_add', 'cap_drop'])( + 'rejects prototype flat capability field %s at the legacy create boundary', + async (field) => { + await startRoutingTestApp() + + const response = await post('/api/v1/boxes', { + image: 'alpine:latest', + [field]: [], + }) + + expect(response.status).toBe(400) + }, + ) + it('matches websocket attach upgrades with or without a routing prefix', () => { const service = new BoxliteWsProxyService( {} as any, diff --git a/apps/api/src/boxlite-rest/dto/box-response.dto.ts b/apps/api/src/boxlite-rest/dto/box-response.dto.ts index 7a16cc158..207788816 100644 --- a/apps/api/src/boxlite-rest/dto/box-response.dto.ts +++ b/apps/api/src/boxlite-rest/dto/box-response.dto.ts @@ -5,6 +5,7 @@ */ import { ApiProperty, ApiPropertyOptional, ApiSchema } from '@nestjs/swagger' +import { BoxAdvancedOptionsDto } from '../../box/dto/box-advanced-options.dto' @ApiSchema({ name: 'Box' }) export class BoxResponseDto { @@ -62,6 +63,13 @@ export class BoxResponseDto { }) memory_mib: number + @ApiProperty({ + description: 'Advanced box configuration', + type: BoxAdvancedOptionsDto, + example: { capabilities: { add: ['SYS_ADMIN'], drop: ['NET_RAW'] } }, + }) + advanced: BoxAdvancedOptionsDto + @ApiProperty({ description: 'Labels attached to the box', type: 'object', diff --git a/apps/api/src/boxlite-rest/dto/create-box.dto.spec.ts b/apps/api/src/boxlite-rest/dto/create-box.dto.spec.ts index 962547cb3..35a3ee18d 100644 --- a/apps/api/src/boxlite-rest/dto/create-box.dto.spec.ts +++ b/apps/api/src/boxlite-rest/dto/create-box.dto.spec.ts @@ -42,9 +42,7 @@ describe('CreateBoxDto resource minimums', () => { describe('CreateBoxDto lifecycle policy', () => { it('accepts second-based lifecycle fields', async () => { - const errors = await validate( - plainToInstance(CreateBoxDto, { auto_pause: 900, auto_delete: 604800 }), - ) + const errors = await validate(plainToInstance(CreateBoxDto, { auto_pause: 900, auto_delete: 604800 })) expect(errors).toHaveLength(0) }) @@ -124,3 +122,77 @@ describe('CreateBoxDto network validation', () => { expect(JSON.stringify(errors)).toContain('isIn') }) }) + +describe('CreateBoxDto capability validation', () => { + it.each([ + ['advanced', { advanced: null }], + ['advanced.capabilities', { advanced: { capabilities: null } }], + ['advanced.capabilities.add', { advanced: { capabilities: { add: null } } }], + ['advanced.capabilities.drop', { advanced: { capabilities: { drop: null } } }], + ])('rejects explicit null for %s', async (_field, payload) => { + const errors = await validate(plainToInstance(CreateBoxDto, payload)) + + expect(errors).not.toHaveLength(0) + }) + + it('accepts Docker-style capability names', async () => { + const errors = await validate( + plainToInstance(CreateBoxDto, { + advanced: { + capabilities: { + add: ['sys_admin', 'CAP_NET_ADMIN', 'ALL'], + drop: ['NET_RAW'], + }, + }, + }), + ) + + expect(errors).toHaveLength(0) + }) + + it('accepts a syntactically valid capability that a newer guest may support', async () => { + const errors = await validate( + plainToInstance(CreateBoxDto, { + advanced: { capabilities: { add: ['FUTURE_KERNEL_FEATURE'] } }, + }), + ) + + expect(errors).toHaveLength(0) + }) + + it('rejects malformed capability names', async () => { + for (const capability of ['NET-ADMIN', '123', 'ß']) { + const errors = await validate( + plainToInstance(CreateBoxDto, { + advanced: { capabilities: { add: [capability] } }, + }), + ) + + expect(JSON.stringify(errors)).toContain('isLinuxCapabilityName') + } + }) +}) + +describe('CreateBoxDto unsupported cloud options', () => { + it.each([ + ['rootfs_path', '/tmp/rootfs'], + ['tty', true], + [ + 'secrets', + [ + { + name: 'registry-token', + value: 'redacted-test-value', + hosts: ['registry.example.com'], + placeholder: '', + }, + ], + ], + ])('rejects %s instead of silently dropping it', async (field, value) => { + const errors = await validate(plainToInstance(CreateBoxDto, { [field]: value })) + + expect(errors.find((error) => error.property === field)?.constraints).toHaveProperty( + 'isUnsupportedCloudCreateOption', + ) + }) +}) diff --git a/apps/api/src/boxlite-rest/dto/create-box.dto.ts b/apps/api/src/boxlite-rest/dto/create-box.dto.ts index 9e0310371..f27060db6 100644 --- a/apps/api/src/boxlite-rest/dto/create-box.dto.ts +++ b/apps/api/src/boxlite-rest/dto/create-box.dto.ts @@ -16,11 +16,14 @@ import { Min, IsIn, Validate, + ValidateIf, ValidateNested, + ValidationArguments, ValidatorConstraint, ValidatorConstraintInterface, } from 'class-validator' import { isValidNetworkAllowEntry, MAX_NETWORK_ALLOW_LIST_ENTRIES } from '../../box/utils/network-validation.util' +import { CreateBoxAdvancedOptionsDto } from '../../box/dto/create-box.dto' @ValidatorConstraint({ name: 'isNetworkAllowEntry', async: false }) class IsNetworkAllowEntryConstraint implements ValidatorConstraintInterface { @@ -33,6 +36,17 @@ class IsNetworkAllowEntryConstraint implements ValidatorConstraintInterface { } } +@ValidatorConstraint({ name: 'isUnsupportedCloudCreateOption', async: false }) +class IsUnsupportedCloudCreateOptionConstraint implements ValidatorConstraintInterface { + validate(value: unknown): boolean { + return value === undefined + } + + defaultMessage(args: ValidationArguments): string { + return `${args.property} is not supported by the cloud REST API` + } +} + export class NetworkSpecDto { @IsIn(['enabled', 'disabled']) mode: 'enabled' | 'disabled' @@ -54,6 +68,13 @@ export class CreateBoxDto { @IsString() image?: string + // The local runtime can consume an OCI layout from its own filesystem, but + // a cloud API path cannot safely interpret a client-local path. Declare the + // wire field so strict validation reports the real incompatibility instead + // of treating it as an unknown option or silently dropping it. + @Validate(IsUnsupportedCloudCreateOptionConstraint) + rootfs_path?: string + // A box with 0 vCPUs can never boot (libkrun set_vm_config(0, ...) → EINVAL), // so reject undersized resources at the request boundary instead of accepting // a box that fails to start. @@ -80,6 +101,12 @@ export class CreateBoxDto { @IsObject() env?: Record + // Secret substitution has no persisted control-plane/runner contract yet. + // Reject it at both validation and mapper boundaries until that contract + // exists; accepting and discarding a secret would be unsafe. + @Validate(IsUnsupportedCloudCreateOptionConstraint) + secrets?: unknown[] + @IsOptional() @IsArray() entrypoint?: string[] @@ -92,10 +119,22 @@ export class CreateBoxDto { @IsString() user?: string + @ValidateIf((_object, value) => value !== undefined) + @IsObject() + @ValidateNested() + @Type(() => CreateBoxAdvancedOptionsDto) + advanced?: CreateBoxAdvancedOptionsDto + @IsOptional() @IsBoolean() detach?: boolean + // The runner create DTO currently has no container-init TTY field. Keep the + // strict endpoint fail-closed rather than degrading an interactive request + // to pipes. + @Validate(IsUnsupportedCloudCreateOptionConstraint) + tty?: boolean + @IsOptional() @IsNumber() @Min(0) diff --git a/apps/api/src/boxlite-rest/mappers/box-to-box.mapper.spec.ts b/apps/api/src/boxlite-rest/mappers/box-to-box.mapper.spec.ts index fd0867021..99dcdfc14 100644 --- a/apps/api/src/boxlite-rest/mappers/box-to-box.mapper.spec.ts +++ b/apps/api/src/boxlite-rest/mappers/box-to-box.mapper.spec.ts @@ -7,6 +7,40 @@ import { BoxState } from '../../box/enums/box-state.enum' import { boxToBoxResponse, createBoxToCreateBox } from './box-to-box.mapper' describe('BoxLite lifecycle policy mapper', () => { + it.each([ + ['rootfs_path', { rootfs_path: '/tmp/rootfs' }], + ['tty', { tty: true }], + [ + 'secrets', + { + secrets: [ + { + name: 'registry-token', + value: 'redacted-test-value', + hosts: ['registry.example.com'], + placeholder: '', + }, + ], + }, + ], + ])('refuses to silently drop unsupported %s', (_field, request) => { + expect(() => createBoxToCreateBox(request as any)).toThrow('not supported by the cloud REST API') + }) + + it('maps capability overrides into the control-plane DTO', () => { + const mapped = createBoxToCreateBox({ + advanced: { + capabilities: { + add: ['SYS_ADMIN'], + drop: ['CAP_NET_RAW'], + }, + }, + } as any) + + expect(mapped.advanced?.capabilities?.add).toEqual(['SYS_ADMIN']) + expect(mapped.advanced?.capabilities?.drop).toEqual(['CAP_NET_RAW']) + }) + it('maps second-based create fields into the control-plane DTO', () => { const mapped = createBoxToCreateBox({ auto_pause: 1800, @@ -35,6 +69,19 @@ describe('BoxLite lifecycle policy mapper', () => { expect(response.auto_resume).toBe(false) }) + it('returns the persisted capability policy', () => { + const response = boxToBoxResponse({ + id: 'box-1', + name: 'demo', + state: BoxState.STARTED, + labels: {}, + advanced: { capabilities: { add: ['SYS_ADMIN'], drop: ['NET_RAW'] } }, + } as any) + + expect(response.advanced.capabilities.add).toEqual(['SYS_ADMIN']) + expect(response.advanced.capabilities.drop).toEqual(['NET_RAW']) + }) + it('defaults auto_resume to true when missing', () => { const response = boxToBoxResponse({ id: 'box-1', @@ -44,5 +91,6 @@ describe('BoxLite lifecycle policy mapper', () => { } as any) expect(response.auto_resume).toBe(true) + expect(response.advanced).toEqual({ capabilities: { add: [], drop: [] } }) }) }) diff --git a/apps/api/src/boxlite-rest/mappers/box-to-box.mapper.ts b/apps/api/src/boxlite-rest/mappers/box-to-box.mapper.ts index f74a91e78..5f398fbc9 100644 --- a/apps/api/src/boxlite-rest/mappers/box-to-box.mapper.ts +++ b/apps/api/src/boxlite-rest/mappers/box-to-box.mapper.ts @@ -4,6 +4,7 @@ * SPDX-License-Identifier: AGPL-3.0 */ +import { BadRequestException } from '@nestjs/common' import { BoxDto } from '../../box/dto/box.dto' import { BoxState } from '../../box/enums/box-state.enum' import { @@ -14,6 +15,7 @@ import { import { BoxResponseDto } from '../dto/box-response.dto' import { CreateBoxDto as RestCreateBoxDto } from '../dto/create-box.dto' import { CreateBoxDto } from '../../box/dto/create-box.dto' +import { normalizeBoxAdvancedOptions } from '../../box/common/box-advanced-options' export function boxToBoxResponse(box: BoxDto): BoxResponseDto { return { @@ -25,6 +27,7 @@ export function boxToBoxResponse(box: BoxDto): BoxResponseDto { image: box.image || '', cpus: box.cpu || 1, memory_mib: (box.memory || 1) * 1024, + advanced: normalizeBoxAdvancedOptions(box.advanced), labels: box.labels || {}, auto_pause: box.autoPause ?? DEFAULT_AUTO_PAUSE_SECONDS, auto_delete: box.autoDelete ?? AUTO_DELETE_DISABLED, @@ -33,11 +36,14 @@ export function boxToBoxResponse(box: BoxDto): BoxResponseDto { } export function createBoxToCreateBox(dto: RestCreateBoxDto, target?: string): CreateBoxDto { + rejectUnsupportedCloudCreateOptions(dto) + const createDto = new CreateBoxDto() createDto.name = dto.name createDto.image = dto.image createDto.user = dto.user createDto.env = dto.env + createDto.advanced = dto.advanced createDto.cpu = dto.cpus createDto.memory = dto.memory_mib ? Math.ceil(dto.memory_mib / 1024) : undefined createDto.disk = dto.disk_size_gb @@ -53,6 +59,17 @@ export function createBoxToCreateBox(dto: RestCreateBoxDto, target?: string): Cr return createDto } +function rejectUnsupportedCloudCreateOptions(dto: RestCreateBoxDto): void { + const unsupportedFields = (['rootfs_path', 'tty', 'secrets'] as const).filter((field) => dto[field] !== undefined) + if (unsupportedFields.length === 0) { + return + } + + throw new BadRequestException( + `${unsupportedFields.join(', ')} is not supported by the cloud REST API`, + ) +} + function mapState(state: string | BoxState | undefined): string { switch (state) { case BoxState.STARTED: diff --git a/apps/api/src/migrations/pre-deploy/1785000000000-add-box-capabilities-migration.spec.ts b/apps/api/src/migrations/pre-deploy/1785000000000-add-box-capabilities-migration.spec.ts new file mode 100644 index 000000000..9f42bace4 --- /dev/null +++ b/apps/api/src/migrations/pre-deploy/1785000000000-add-box-capabilities-migration.spec.ts @@ -0,0 +1,40 @@ +import { QueryRunner } from 'typeorm' +import { AddBoxCapabilities1785000000000 } from './1785000000000-add-box-capabilities-migration' + +describe('AddBoxCapabilities1785000000000', () => { + it('stores advanced options as one nested JSONB object', async () => { + const query = jest.fn().mockResolvedValue(undefined) + const queryRunner = { query } as unknown as QueryRunner + + await new AddBoxCapabilities1785000000000().up(queryRunner) + + expect(query).toHaveBeenCalledTimes(2) + expect(query.mock.calls[0][0]).toContain(`ADD "advanced" jsonb`) + expect(query.mock.calls[0][0]).toContain(`{"capabilities":{"add":[],"drop":[]}}`) + expect(query.mock.calls[1][0]).toContain(`ALTER TABLE "runner" ADD "features"`) + }) + + it('refuses to discard a persisted custom capability policy on rollback', async () => { + const query = jest.fn().mockResolvedValueOnce([{ hasCustomPolicy: true }]) + const queryRunner = { query } as unknown as QueryRunner + + await expect(new AddBoxCapabilities1785000000000().down(queryRunner)).rejects.toThrow( + 'custom Linux capability policies', + ) + expect(query).toHaveBeenCalledTimes(1) + expect(query.mock.calls[0][0]).toContain('SELECT EXISTS') + }) + + it('allows rollback when every box uses the baseline policy', async () => { + const query = jest.fn().mockResolvedValueOnce([{ hasCustomPolicy: false }]) + const queryRunner = { query } as unknown as QueryRunner + + await new AddBoxCapabilities1785000000000().down(queryRunner) + + expect(query).toHaveBeenCalledTimes(3) + expect(query.mock.calls.slice(1).map(([sql]) => sql)).toEqual([ + `ALTER TABLE "runner" DROP COLUMN "features"`, + `ALTER TABLE "box" DROP COLUMN "advanced"`, + ]) + }) +}) diff --git a/apps/api/src/migrations/pre-deploy/1785000000000-add-box-capabilities-migration.ts b/apps/api/src/migrations/pre-deploy/1785000000000-add-box-capabilities-migration.ts new file mode 100644 index 000000000..46217c05a --- /dev/null +++ b/apps/api/src/migrations/pre-deploy/1785000000000-add-box-capabilities-migration.ts @@ -0,0 +1,30 @@ +import { MigrationInterface, QueryRunner } from 'typeorm' + +export class AddBoxCapabilities1785000000000 implements MigrationInterface { + name = 'AddBoxCapabilities1785000000000' + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "box" ADD "advanced" jsonb NOT NULL DEFAULT '{"capabilities":{"add":[],"drop":[]}}'::jsonb`, + ) + await queryRunner.query(`ALTER TABLE "runner" ADD "features" jsonb NOT NULL DEFAULT '[]'::jsonb`) + } + + public async down(queryRunner: QueryRunner): Promise { + const [{ hasCustomPolicy }] = (await queryRunner.query(` + SELECT EXISTS ( + SELECT 1 + FROM "box" + WHERE "advanced" <> '{"capabilities":{"add":[],"drop":[]}}'::jsonb + ) AS "hasCustomPolicy" + `)) as Array<{ hasCustomPolicy: boolean }> + if (hasCustomPolicy) { + throw new Error( + 'Cannot roll back advanced options while boxes have custom Linux capability policies', + ) + } + + await queryRunner.query(`ALTER TABLE "runner" DROP COLUMN "features"`) + await queryRunner.query(`ALTER TABLE "box" DROP COLUMN "advanced"`) + } +} diff --git a/apps/dashboard/src/mocks/fixtures.ts b/apps/dashboard/src/mocks/fixtures.ts index a0f0c8a3d..eecde6a94 100644 --- a/apps/dashboard/src/mocks/fixtures.ts +++ b/apps/dashboard/src/mocks/fixtures.ts @@ -115,6 +115,7 @@ function buildBox(overrides: Partial & Pick): class: BoxClassEnum.SMALL, toolboxProxyUrl: 'https://mock.local', ...overrides, + advanced: overrides.advanced ?? { capabilities: { add: [], drop: [] } }, } } diff --git a/apps/hack/go-client/postprocess.sh b/apps/hack/go-client/postprocess.sh index 5fa0a2262..af9db5a8e 100755 --- a/apps/hack/go-client/postprocess.sh +++ b/apps/hack/go-client/postprocess.sh @@ -30,4 +30,17 @@ EOF grep -q 'UserAgent:.*"[^"]*"' "$PROJECT_ROOT/configuration.go" || { echo "ERROR: UserAgent string not found in configuration.go" >&2; exit 1; } sed -i "s|UserAgent: *\"[^\"]*\"|UserAgent: \"${CLIENT_NAME}/\" + ClientVersion|" "$PROJECT_ROOT/configuration.go" +# encoding/json accepts null for slices, so preserve the OpenAPI non-null +# contract for LinuxCapabilities' required array fields after regeneration. +CAPABILITIES_MODEL="$PROJECT_ROOT/model_linux_capabilities.go" +REQUIRED_VALUE_CHECK='if _, exists := allProperties[requiredProperty]; !exists {' +NULL_SAFE_REQUIRED_VALUE_CHECK='if value, exists := allProperties[requiredProperty]; !exists || value == nil {' + +if [ "$(grep -Fc "$REQUIRED_VALUE_CHECK" "$CAPABILITIES_MODEL")" -ne 1 ]; then + echo "ERROR: LinuxCapabilities required-value check not found exactly once" >&2 + exit 1 +fi +sed -i 's/if _, exists := allProperties\[requiredProperty\]; !exists {/if value, exists := allProperties[requiredProperty]; !exists || value == nil {/' "$CAPABILITIES_MODEL" +grep -Fq "$NULL_SAFE_REQUIRED_VALUE_CHECK" "$CAPABILITIES_MODEL" || { echo "ERROR: LinuxCapabilities null guard was not applied" >&2; exit 1; } + echo "Postprocessed Go client at $PROJECT_ROOT" diff --git a/apps/libs/api-client/src/docs/Box.md b/apps/libs/api-client/src/docs/Box.md index c4a2da0af..171fe821f 100644 --- a/apps/libs/api-client/src/docs/Box.md +++ b/apps/libs/api-client/src/docs/Box.md @@ -10,6 +10,7 @@ Name | Type | Description | Notes **name** | **string** | The name of the box | [default to undefined] **user** | **string** | The user associated with the project | [default to undefined] **env** | **{ [key: string]: string; }** | Environment variables for the box | [default to undefined] +**advanced** | [**BoxAdvancedOptions**](BoxAdvancedOptions.md) | Advanced box configuration | [default to undefined] **labels** | **{ [key: string]: string; }** | Labels for the box | [default to undefined] **_public** | **boolean** | Whether the box http preview is public | [default to undefined] **networkBlockAll** | **boolean** | Whether to block all network access for the box | [default to undefined] @@ -46,6 +47,7 @@ const instance: Box = { name, user, env, + advanced, labels, _public, networkBlockAll, diff --git a/apps/libs/api-client/src/docs/BoxAdvancedOptions.md b/apps/libs/api-client/src/docs/BoxAdvancedOptions.md new file mode 100644 index 000000000..2c9f14be0 --- /dev/null +++ b/apps/libs/api-client/src/docs/BoxAdvancedOptions.md @@ -0,0 +1,9 @@ +# BoxAdvancedOptions + +## Properties + +Name | Type | Description | Notes +------------ | ------------- | ------------- | ------------- +**capabilities** | [**LinuxCapabilities**](LinuxCapabilities.md) | Linux capability policy | [default to undefined] + +[[Back to Model list]](../README.md#documentation-for-models) diff --git a/apps/libs/api-client/src/docs/JobType.md b/apps/libs/api-client/src/docs/JobType.md index 4b30a68bb..6eeed67a4 100644 --- a/apps/libs/api-client/src/docs/JobType.md +++ b/apps/libs/api-client/src/docs/JobType.md @@ -6,6 +6,8 @@ The type of the job * `CREATE_BOX` (value: `'CREATE_BOX'`) +* `CREATE_BOX_WITH_CAPABILITIES_V2` (value: `'CREATE_BOX_WITH_CAPABILITIES_V2'`) + * `START_BOX` (value: `'START_BOX'`) * `STOP_BOX` (value: `'STOP_BOX'`) @@ -20,6 +22,8 @@ The type of the job * `RECOVER_BOX` (value: `'RECOVER_BOX'`) +* `RECOVER_BOX_WITH_CAPABILITIES_V2` (value: `'RECOVER_BOX_WITH_CAPABILITIES_V2'`) + * `INSPECT_ARTIFACT_IN_REGISTRY` (value: `'INSPECT_ARTIFACT_IN_REGISTRY'`) * `REMOVE_ARTIFACT` (value: `'REMOVE_ARTIFACT'`) diff --git a/apps/libs/api-client/src/docs/LinuxCapabilities.md b/apps/libs/api-client/src/docs/LinuxCapabilities.md new file mode 100644 index 000000000..891797d2c --- /dev/null +++ b/apps/libs/api-client/src/docs/LinuxCapabilities.md @@ -0,0 +1,10 @@ +# LinuxCapabilities + +## Properties + +Name | Type | Description | Notes +------------ | ------------- | ------------- | ------------- +**add** | **Array<string>** | Linux capabilities added to the default container capability set | [default to undefined] +**drop** | **Array<string>** | Linux capabilities removed from the container capability set | [default to undefined] + +[[Back to Model list]](../README.md#documentation-for-models) diff --git a/apps/libs/api-client/src/models/box-advanced-options.ts b/apps/libs/api-client/src/models/box-advanced-options.ts new file mode 100644 index 000000000..c8b4dc604 --- /dev/null +++ b/apps/libs/api-client/src/models/box-advanced-options.ts @@ -0,0 +1,15 @@ +/* tslint:disable */ +/* eslint-disable */ +/** + * BoxLite + * BoxLite AI platform API Docs + * + * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). + * Do not edit the class manually. + */ + +import type { LinuxCapabilities } from './linux-capabilities' + +export interface BoxAdvancedOptions { + capabilities: LinuxCapabilities +} diff --git a/apps/libs/api-client/src/models/box.ts b/apps/libs/api-client/src/models/box.ts index 7fb4eba20..7bf931db6 100644 --- a/apps/libs/api-client/src/models/box.ts +++ b/apps/libs/api-client/src/models/box.ts @@ -12,144 +12,148 @@ * Do not edit the class manually. */ - // May contain unused imports in some cases // @ts-ignore -import type { BoxDesiredState } from './box-desired-state'; +import type { BoxDesiredState } from './box-desired-state' +// May contain unused imports in some cases +// @ts-ignore +import type { BoxState } from './box-state' // May contain unused imports in some cases // @ts-ignore -import type { BoxState } from './box-state'; +import type { BoxVolume } from './box-volume' // May contain unused imports in some cases // @ts-ignore -import type { BoxVolume } from './box-volume'; +import type { BoxAdvancedOptions } from './box-advanced-options' export interface Box { - /** - * The public 12-character Box ID - */ - 'id': string; - /** - * The organization ID of the box - */ - 'organizationId': string; - /** - * The name of the box - */ - 'name': string; - /** - * The user associated with the project - */ - 'user': string; - /** - * Environment variables for the box - */ - 'env': { [key: string]: string; }; - /** - * Labels for the box - */ - 'labels': { [key: string]: string; }; - /** - * Whether the box http preview is public - */ - 'public': boolean; - /** - * Whether to block all network access for the box - */ - 'networkBlockAll': boolean; - /** - * Comma-separated list of allowed CIDR network addresses for the box - */ - 'networkAllowList'?: string; - /** - * The target environment for the box - */ - 'target': string; - /** - * The image used for the box - */ - 'image'?: string; - /** - * The CPU quota for the box - */ - 'cpu': number; - /** - * The GPU quota for the box - */ - 'gpu': number; - /** - * The memory quota for the box - */ - 'memory': number; - /** - * The disk quota for the box - */ - 'disk': number; - /** - * The state of the box - */ - 'state'?: BoxState; - /** - * The desired state of the box - */ - 'desiredState'?: BoxDesiredState; - /** - * The error reason of the box - */ - 'errorReason'?: string; - /** - * Whether the box error is recoverable. - */ - 'recoverable'?: boolean; - /** - * Auto-pause interval in seconds (0 means disabled) - */ - 'autoPause'?: number; - /** - * Auto-delete interval in seconds (0 means disabled) - */ - 'autoDelete'?: number; - /** - * Whether the box should be automatically resumed on proxy access - */ - 'autoResume'?: boolean; - /** - * Array of volumes attached to the box - */ - 'volumes'?: Array; - /** - * The creation timestamp of the box - */ - 'createdAt'?: string; - /** - * The last update timestamp of the box - */ - 'updatedAt'?: string; - /** - * The class of the box - * @deprecated - */ - 'class'?: BoxClassEnum; - /** - * The version of the daemon running in the box - */ - 'daemonVersion'?: string; - /** - * The runner ID of the box - */ - 'runnerId'?: string; - /** - * The toolbox proxy URL for the box - */ - 'toolboxProxyUrl': string; + /** + * The public 12-character Box ID + */ + id: string + /** + * The organization ID of the box + */ + organizationId: string + /** + * The name of the box + */ + name: string + /** + * The user associated with the project + */ + user: string + /** + * Environment variables for the box + */ + env: { [key: string]: string } + /** + * Advanced box configuration + */ + advanced: BoxAdvancedOptions + /** + * Labels for the box + */ + labels: { [key: string]: string } + /** + * Whether the box http preview is public + */ + public: boolean + /** + * Whether to block all network access for the box + */ + networkBlockAll: boolean + /** + * Comma-separated list of allowed CIDR network addresses for the box + */ + networkAllowList?: string + /** + * The target environment for the box + */ + target: string + /** + * The image used for the box + */ + image?: string + /** + * The CPU quota for the box + */ + cpu: number + /** + * The GPU quota for the box + */ + gpu: number + /** + * The memory quota for the box + */ + memory: number + /** + * The disk quota for the box + */ + disk: number + /** + * The state of the box + */ + state?: BoxState + /** + * The desired state of the box + */ + desiredState?: BoxDesiredState + /** + * The error reason of the box + */ + errorReason?: string + /** + * Whether the box error is recoverable. + */ + recoverable?: boolean + /** + * Auto-pause interval in seconds (0 means disabled) + */ + autoPause?: number + /** + * Auto-delete interval in seconds (0 means disabled) + */ + autoDelete?: number + /** + * Whether the box should be automatically resumed on proxy access + */ + autoResume?: boolean + /** + * Array of volumes attached to the box + */ + volumes?: Array + /** + * The creation timestamp of the box + */ + createdAt?: string + /** + * The last update timestamp of the box + */ + updatedAt?: string + /** + * The class of the box + * @deprecated + */ + class?: BoxClassEnum + /** + * The version of the daemon running in the box + */ + daemonVersion?: string + /** + * The runner ID of the box + */ + runnerId?: string + /** + * The toolbox proxy URL for the box + */ + toolboxProxyUrl: string } export const BoxClassEnum = { - SMALL: 'small', - MEDIUM: 'medium', - LARGE: 'large', - UNKNOWN_DEFAULT_OPEN_API: '11184809', -} as const; - -export type BoxClassEnum = typeof BoxClassEnum[keyof typeof BoxClassEnum]; - + SMALL: 'small', + MEDIUM: 'medium', + LARGE: 'large', + UNKNOWN_DEFAULT_OPEN_API: '11184809', +} as const +export type BoxClassEnum = (typeof BoxClassEnum)[keyof typeof BoxClassEnum] diff --git a/apps/libs/api-client/src/models/index.ts b/apps/libs/api-client/src/models/index.ts index 7fda66017..61ebc85ef 100644 --- a/apps/libs/api-client/src/models/index.ts +++ b/apps/libs/api-client/src/models/index.ts @@ -32,6 +32,7 @@ export * from './api-key-list'; export * from './api-key-response'; export * from './audit-log'; export * from './box'; +export * from './box-advanced-options'; export * from './box-class'; export * from './box-desired-state'; export * from './box-labels'; @@ -55,6 +56,7 @@ export * from './job'; export * from './job-status'; export * from './job-type'; export * from './log-entry'; +export * from './linux-capabilities'; export * from './metric-data-point'; export * from './metric-series'; export * from './metrics-response'; diff --git a/apps/libs/api-client/src/models/job-type.ts b/apps/libs/api-client/src/models/job-type.ts index 3c31d1e6f..2f1d10422 100644 --- a/apps/libs/api-client/src/models/job-type.ts +++ b/apps/libs/api-client/src/models/job-type.ts @@ -20,6 +20,7 @@ export const JobType = { CREATE_BOX: 'CREATE_BOX', + CREATE_BOX_WITH_CAPABILITIES_V2: 'CREATE_BOX_WITH_CAPABILITIES_V2', START_BOX: 'START_BOX', STOP_BOX: 'STOP_BOX', DESTROY_BOX: 'DESTROY_BOX', @@ -27,6 +28,7 @@ export const JobType = { CREATE_BACKUP: 'CREATE_BACKUP', PULL_ARTIFACT: 'PULL_ARTIFACT', RECOVER_BOX: 'RECOVER_BOX', + RECOVER_BOX_WITH_CAPABILITIES_V2: 'RECOVER_BOX_WITH_CAPABILITIES_V2', INSPECT_ARTIFACT_IN_REGISTRY: 'INSPECT_ARTIFACT_IN_REGISTRY', REMOVE_ARTIFACT: 'REMOVE_ARTIFACT', UPDATE_BOX_NETWORK_SETTINGS: 'UPDATE_BOX_NETWORK_SETTINGS', @@ -35,5 +37,3 @@ export const JobType = { export type JobType = typeof JobType[keyof typeof JobType]; - - diff --git a/apps/libs/api-client/src/models/linux-capabilities.ts b/apps/libs/api-client/src/models/linux-capabilities.ts new file mode 100644 index 000000000..c74683106 --- /dev/null +++ b/apps/libs/api-client/src/models/linux-capabilities.ts @@ -0,0 +1,16 @@ +/* tslint:disable */ +/* eslint-disable */ +/** + * BoxLite + * BoxLite AI platform API Docs + * + * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). + * Do not edit the class manually. + */ + +export interface LinuxCapabilities { + /** Linux capabilities added to the default container capability set. */ + add: Array + /** Linux capabilities removed from the container capability set. */ + drop: Array +} diff --git a/apps/libs/api-client/src/models/runner-healthcheck.ts b/apps/libs/api-client/src/models/runner-healthcheck.ts index aec0522d1..2b8deb1c2 100644 --- a/apps/libs/api-client/src/models/runner-healthcheck.ts +++ b/apps/libs/api-client/src/models/runner-healthcheck.ts @@ -12,38 +12,40 @@ * Do not edit the class manually. */ - // May contain unused imports in some cases // @ts-ignore -import type { RunnerHealthMetrics } from './runner-health-metrics'; +import type { RunnerHealthMetrics } from './runner-health-metrics' // May contain unused imports in some cases // @ts-ignore -import type { RunnerServiceHealth } from './runner-service-health'; +import type { RunnerServiceHealth } from './runner-service-health' export interface RunnerHealthcheck { - /** - * Runner metrics - */ - 'metrics'?: RunnerHealthMetrics; - /** - * Health status of individual services on the runner - */ - 'serviceHealth'?: Array; - /** - * Runner domain - */ - 'domain'?: string; - /** - * Runner proxy URL - */ - 'proxyUrl'?: string; - /** - * Runner API URL - */ - 'apiUrl'?: string; - /** - * Runner app version - */ - 'appVersion': string; + /** + * Optional runner features used for rollout negotiation + */ + features?: Array + /** + * Runner metrics + */ + metrics?: RunnerHealthMetrics + /** + * Health status of individual services on the runner + */ + serviceHealth?: Array + /** + * Runner domain + */ + domain?: string + /** + * Runner proxy URL + */ + proxyUrl?: string + /** + * Runner API URL + */ + apiUrl?: string + /** + * Runner app version + */ + appVersion: string } - diff --git a/apps/libs/runner-api-client/src/.openapi-generator/FILES b/apps/libs/runner-api-client/src/.openapi-generator/FILES index c380051b3..33f5db08a 100644 --- a/apps/libs/runner-api-client/src/.openapi-generator/FILES +++ b/apps/libs/runner-api-client/src/.openapi-generator/FILES @@ -9,12 +9,15 @@ api/snapshots-api.ts base.ts common.ts configuration.ts +docs/AdvancedBoxOptionsDTO.md docs/BoxApi.md docs/BoxInfoResponse.md docs/BoxliteApi.md docs/BuildSnapshotRequestDTO.md docs/CreateBackupDTO.md docs/CreateBoxDTO.md +docs/CreateBoxWithCapabilitiesDTO.md +docs/ContainerCapabilitiesDTO.md docs/DefaultApi.md docs/DtoVolumeDTO.md docs/EnumsBackupState.md @@ -25,6 +28,7 @@ docs/IsRecoverableDTO.md docs/IsRecoverableResponse.md docs/PullSnapshotRequestDTO.md docs/RecoverBoxDTO.md +docs/RecoverBoxWithCapabilitiesDTO.md docs/RegistryDTO.md docs/RunnerInfoResponseDTO.md docs/RunnerMetrics.md @@ -39,10 +43,13 @@ docs/TagImageRequestDTO.md docs/UpdateNetworkSettingsDTO.md git_push.sh index.ts +models/advanced-box-options-dto.ts models/box-info-response.ts models/build-snapshot-request-dto.ts models/create-backup-dto.ts models/create-box-dto.ts +models/create-box-with-capabilities-dto.ts +models/container-capabilities-dto.ts models/dto-volume-dto.ts models/enums-backup-state.ts models/enums-box-state.ts @@ -53,6 +60,7 @@ models/is-recoverable-dto.ts models/is-recoverable-response.ts models/pull-snapshot-request-dto.ts models/recover-box-dto.ts +models/recover-box-with-capabilities-dto.ts models/registry-dto.ts models/runner-info-response-dto.ts models/runner-metrics.ts diff --git a/apps/libs/runner-api-client/src/api/box-api.ts b/apps/libs/runner-api-client/src/api/box-api.ts index 3d1799b62..ca1387675 100644 --- a/apps/libs/runner-api-client/src/api/box-api.ts +++ b/apps/libs/runner-api-client/src/api/box-api.ts @@ -27,6 +27,7 @@ import type { BoxInfoResponse } from '../models'; import type { CreateBackupDTO } from '../models'; // @ts-ignore import type { CreateBoxDTO } from '../models'; +import type { CreateBoxWithCapabilitiesDTO } from '../models'; // @ts-ignore import type { ErrorResponse } from '../models'; // @ts-ignore @@ -35,6 +36,7 @@ import type { IsRecoverableDTO } from '../models'; import type { IsRecoverableResponse } from '../models'; // @ts-ignore import type { RecoverBoxDTO } from '../models'; +import type { RecoverBoxWithCapabilitiesDTO } from '../models'; // @ts-ignore import type { StartBoxResponse } from '../models'; // @ts-ignore @@ -126,6 +128,44 @@ export const BoxApiAxiosParamCreator = function (configuration?: Configuration) options: localVarRequestOptions, }; }, + /** + * Fail-closed create contract for capability-bearing requests + * @summary Create a box with a capability policy + * @param {CreateBoxWithCapabilitiesDTO} box Create box with capabilities + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + createWithCapabilities: async (box: CreateBoxWithCapabilitiesDTO, options: RawAxiosRequestConfig = {}): Promise => { + // verify required parameter 'box' is not null or undefined + assertParamExists('createWithCapabilities', 'box', box) + const localVarPath = `/boxes/strict`; + // use dummy base URL string because the URL constructor only accepts absolute URLs. + const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL); + let baseOptions; + if (configuration) { + baseOptions = configuration.baseOptions; + } + + const localVarRequestOptions = { method: 'POST', ...baseOptions, ...options}; + const localVarHeaderParameter = {} as any; + const localVarQueryParameter = {} as any; + + // authentication Bearer required + await setApiKeyToObject(localVarHeaderParameter, "Authorization", configuration) + + localVarHeaderParameter['Content-Type'] = 'application/json'; + localVarHeaderParameter['Accept'] = 'application/json'; + + setSearchParams(localVarUrlObj, localVarQueryParameter); + let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {}; + localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers}; + localVarRequestOptions.data = serializeDataIfNeeded(box, localVarRequestOptions, configuration) + + return { + url: toPathString(localVarUrlObj), + options: localVarRequestOptions, + }; + }, /** * Destroy box * @summary Destroy box @@ -321,6 +361,48 @@ export const BoxApiAxiosParamCreator = function (configuration?: Configuration) options: localVarRequestOptions, }; }, + /** + * Fail-closed recovery contract for capability-bearing requests + * @summary Recover a box with a capability policy + * @param {string} boxId Box ID + * @param {RecoverBoxWithCapabilitiesDTO} recovery Recovery parameters with capabilities + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + recoverWithCapabilities: async (boxId: string, recovery: RecoverBoxWithCapabilitiesDTO, options: RawAxiosRequestConfig = {}): Promise => { + // verify required parameter 'boxId' is not null or undefined + assertParamExists('recoverWithCapabilities', 'boxId', boxId) + // verify required parameter 'recovery' is not null or undefined + assertParamExists('recoverWithCapabilities', 'recovery', recovery) + const localVarPath = `/boxes/{boxId}/recover/strict` + .replace('{boxId}', encodeURIComponent(String(boxId))); + // use dummy base URL string because the URL constructor only accepts absolute URLs. + const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL); + let baseOptions; + if (configuration) { + baseOptions = configuration.baseOptions; + } + + const localVarRequestOptions = { method: 'POST', ...baseOptions, ...options}; + const localVarHeaderParameter = {} as any; + const localVarQueryParameter = {} as any; + + // authentication Bearer required + await setApiKeyToObject(localVarHeaderParameter, "Authorization", configuration) + + localVarHeaderParameter['Content-Type'] = 'application/json'; + localVarHeaderParameter['Accept'] = 'application/json'; + + setSearchParams(localVarUrlObj, localVarQueryParameter); + let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {}; + localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers}; + localVarRequestOptions.data = serializeDataIfNeeded(recovery, localVarRequestOptions, configuration) + + return { + url: toPathString(localVarUrlObj), + options: localVarRequestOptions, + }; + }, /** * Start box * @summary Start box @@ -484,6 +566,19 @@ export const BoxApiFp = function(configuration?: Configuration) { const localVarOperationServerBasePath = operationServerMap['BoxApi.createBackup']?.[localVarOperationServerIndex]?.url; return (axios, basePath) => createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration)(axios, localVarOperationServerBasePath || basePath); }, + /** + * Fail-closed create contract for capability-bearing requests + * @summary Create a box with a capability policy + * @param {CreateBoxWithCapabilitiesDTO} box Create box with capabilities + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + async createWithCapabilities(box: CreateBoxWithCapabilitiesDTO, options?: RawAxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise> { + const localVarAxiosArgs = await localVarAxiosParamCreator.createWithCapabilities(box, options); + const localVarOperationServerIndex = configuration?.serverIndex ?? 0; + const localVarOperationServerBasePath = operationServerMap['BoxApi.createWithCapabilities']?.[localVarOperationServerIndex]?.url; + return (axios, basePath) => createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration)(axios, localVarOperationServerBasePath || basePath); + }, /** * Destroy box * @summary Destroy box @@ -551,6 +646,20 @@ export const BoxApiFp = function(configuration?: Configuration) { const localVarOperationServerBasePath = operationServerMap['BoxApi.recover']?.[localVarOperationServerIndex]?.url; return (axios, basePath) => createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration)(axios, localVarOperationServerBasePath || basePath); }, + /** + * Fail-closed recovery contract for capability-bearing requests + * @summary Recover a box with a capability policy + * @param {string} boxId Box ID + * @param {RecoverBoxWithCapabilitiesDTO} recovery Recovery parameters with capabilities + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + async recoverWithCapabilities(boxId: string, recovery: RecoverBoxWithCapabilitiesDTO, options?: RawAxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise> { + const localVarAxiosArgs = await localVarAxiosParamCreator.recoverWithCapabilities(boxId, recovery, options); + const localVarOperationServerIndex = configuration?.serverIndex ?? 0; + const localVarOperationServerBasePath = operationServerMap['BoxApi.recoverWithCapabilities']?.[localVarOperationServerIndex]?.url; + return (axios, basePath) => createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration)(axios, localVarOperationServerBasePath || basePath); + }, /** * Start box * @summary Start box @@ -624,6 +733,16 @@ export const BoxApiFactory = function (configuration?: Configuration, basePath?: createBackup(boxId: string, box: CreateBackupDTO, options?: RawAxiosRequestConfig): AxiosPromise { return localVarFp.createBackup(boxId, box, options).then((request) => request(axios, basePath)); }, + /** + * Fail-closed create contract for capability-bearing requests + * @summary Create a box with a capability policy + * @param {CreateBoxWithCapabilitiesDTO} box Create box with capabilities + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + createWithCapabilities(box: CreateBoxWithCapabilitiesDTO, options?: RawAxiosRequestConfig): AxiosPromise { + return localVarFp.createWithCapabilities(box, options).then((request) => request(axios, basePath)); + }, /** * Destroy box * @summary Destroy box @@ -676,6 +795,17 @@ export const BoxApiFactory = function (configuration?: Configuration, basePath?: recover(boxId: string, recovery: RecoverBoxDTO, options?: RawAxiosRequestConfig): AxiosPromise { return localVarFp.recover(boxId, recovery, options).then((request) => request(axios, basePath)); }, + /** + * Fail-closed recovery contract for capability-bearing requests + * @summary Recover a box with a capability policy + * @param {string} boxId Box ID + * @param {RecoverBoxWithCapabilitiesDTO} recovery Recovery parameters with capabilities + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + recoverWithCapabilities(boxId: string, recovery: RecoverBoxWithCapabilitiesDTO, options?: RawAxiosRequestConfig): AxiosPromise { + return localVarFp.recoverWithCapabilities(boxId, recovery, options).then((request) => request(axios, basePath)); + }, /** * Start box * @summary Start box @@ -740,6 +870,17 @@ export class BoxApi extends BaseAPI { return BoxApiFp(this.configuration).createBackup(boxId, box, options).then((request) => request(this.axios, this.basePath)); } + /** + * Fail-closed create contract for capability-bearing requests + * @summary Create a box with a capability policy + * @param {CreateBoxWithCapabilitiesDTO} box Create box with capabilities + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + public createWithCapabilities(box: CreateBoxWithCapabilitiesDTO, options?: RawAxiosRequestConfig) { + return BoxApiFp(this.configuration).createWithCapabilities(box, options).then((request) => request(this.axios, this.basePath)); + } + /** * Destroy box * @summary Destroy box @@ -797,6 +938,18 @@ export class BoxApi extends BaseAPI { return BoxApiFp(this.configuration).recover(boxId, recovery, options).then((request) => request(this.axios, this.basePath)); } + /** + * Fail-closed recovery contract for capability-bearing requests + * @summary Recover a box with a capability policy + * @param {string} boxId Box ID + * @param {RecoverBoxWithCapabilitiesDTO} recovery Recovery parameters with capabilities + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + public recoverWithCapabilities(boxId: string, recovery: RecoverBoxWithCapabilitiesDTO, options?: RawAxiosRequestConfig) { + return BoxApiFp(this.configuration).recoverWithCapabilities(boxId, recovery, options).then((request) => request(this.axios, this.basePath)); + } + /** * Start box * @summary Start box diff --git a/apps/libs/runner-api-client/src/docs/AdvancedBoxOptionsDTO.md b/apps/libs/runner-api-client/src/docs/AdvancedBoxOptionsDTO.md new file mode 100644 index 000000000..387505ee0 --- /dev/null +++ b/apps/libs/runner-api-client/src/docs/AdvancedBoxOptionsDTO.md @@ -0,0 +1,9 @@ +# AdvancedBoxOptionsDTO + +## Properties + +Name | Type | Description | Notes +------------ | ------------- | ------------- | ------------- +**capabilities** | [**ContainerCapabilitiesDTO**](ContainerCapabilitiesDTO.md) | Linux capability policy | [default to undefined] + +[[Back to Model list]](../README.md#documentation-for-models) diff --git a/apps/libs/runner-api-client/src/docs/BoxApi.md b/apps/libs/runner-api-client/src/docs/BoxApi.md index d5e70a100..c812171ce 100644 --- a/apps/libs/runner-api-client/src/docs/BoxApi.md +++ b/apps/libs/runner-api-client/src/docs/BoxApi.md @@ -6,11 +6,13 @@ All URIs are relative to *http://localhost* |------------- | ------------- | -------------| |[**create**](#create) | **POST** /boxes | Create a box| |[**createBackup**](#createbackup) | **POST** /boxes/{boxId}/backup | Create box backup| +|[**createWithCapabilities**](#createwithcapabilities) | **POST** /boxes/strict | Create a box with a capability policy| |[**destroy**](#destroy) | **POST** /boxes/{boxId}/destroy | Destroy box| |[**getNetworkSettings**](#getnetworksettings) | **GET** /boxes/{boxId}/network-settings | Get box network settings| |[**info**](#info) | **GET** /boxes/{boxId} | Get box info| |[**isRecoverable**](#isrecoverable) | **POST** /boxes/{boxId}/is-recoverable | Check if box error is recoverable| |[**recover**](#recover) | **POST** /boxes/{boxId}/recover | Recover box from error state| +|[**recoverWithCapabilities**](#recoverwithcapabilities) | **POST** /boxes/{boxId}/recover/strict | Recover a box with a capability policy| |[**start**](#start) | **POST** /boxes/{boxId}/start | Start box| |[**stop**](#stop) | **POST** /boxes/{boxId}/stop | Stop box| |[**updateNetworkSettings**](#updatenetworksettings) | **POST** /boxes/{boxId}/network-settings | Update box network settings| @@ -132,6 +134,63 @@ const { status, data } = await apiInstance.createBackup( [[Back to top]](#) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to Model list]](../README.md#documentation-for-models) [[Back to README]](../README.md) +# **createWithCapabilities** +> StartBoxResponse createWithCapabilities(box) + +Fail-closed create contract for capability-bearing requests + +### Example + +```typescript +import { + BoxApi, + Configuration, + CreateBoxWithCapabilitiesDTO +} from './api'; + +const configuration = new Configuration(); +const apiInstance = new BoxApi(configuration); + +let box: CreateBoxWithCapabilitiesDTO; //Create box with capabilities + +const { status, data } = await apiInstance.createWithCapabilities( + box +); +``` + +### Parameters + +|Name | Type | Description | Notes| +|------------- | ------------- | ------------- | -------------| +| **box** | **CreateBoxWithCapabilitiesDTO**| Create box with capabilities | | + + +### Return type + +**StartBoxResponse** + +### Authorization + +[Bearer](../README.md#Bearer) + +### HTTP request headers + + - **Content-Type**: Not defined + - **Accept**: application/json + + +### HTTP response details +| Status code | Description | Response headers | +|-------------|-------------|------------------| +|**201** | Created | - | +|**400** | Bad Request | - | +|**401** | Unauthorized | - | +|**404** | Not Found | - | +|**409** | Conflict | - | +|**500** | Internal Server Error | - | + +[[Back to top]](#) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to Model list]](../README.md#documentation-for-models) [[Back to README]](../README.md) + # **destroy** > string destroy() @@ -390,6 +449,66 @@ const { status, data } = await apiInstance.recover( | **boxId** | [**string**] | Box ID | defaults to undefined| +### Return type + +**string** + +### Authorization + +[Bearer](../README.md#Bearer) + +### HTTP request headers + + - **Content-Type**: application/json + - **Accept**: application/json + + +### HTTP response details +| Status code | Description | Response headers | +|-------------|-------------|------------------| +|**200** | Box recovered | - | +|**400** | Bad Request | - | +|**401** | Unauthorized | - | +|**404** | Not Found | - | +|**409** | Conflict | - | +|**500** | Internal Server Error | - | + +[[Back to top]](#) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to Model list]](../README.md#documentation-for-models) [[Back to README]](../README.md) + +# **recoverWithCapabilities** +> string recoverWithCapabilities(recovery) + +Fail-closed recovery contract for capability-bearing requests + +### Example + +```typescript +import { + BoxApi, + Configuration, + RecoverBoxWithCapabilitiesDTO +} from './api'; + +const configuration = new Configuration(); +const apiInstance = new BoxApi(configuration); + +let boxId: string; //Box ID (default to undefined) +let recovery: RecoverBoxWithCapabilitiesDTO; //Recovery parameters with capabilities + +const { status, data } = await apiInstance.recoverWithCapabilities( + boxId, + recovery +); +``` + +### Parameters + +|Name | Type | Description | Notes| +|------------- | ------------- | ------------- | -------------| +| **recovery** | **RecoverBoxWithCapabilitiesDTO**| Recovery parameters with capabilities | | +| **boxId** | [**string**] | Box ID | defaults to undefined| + + ### Return type **string** diff --git a/apps/libs/runner-api-client/src/docs/ContainerCapabilitiesDTO.md b/apps/libs/runner-api-client/src/docs/ContainerCapabilitiesDTO.md new file mode 100644 index 000000000..40fd18924 --- /dev/null +++ b/apps/libs/runner-api-client/src/docs/ContainerCapabilitiesDTO.md @@ -0,0 +1,10 @@ +# ContainerCapabilitiesDTO + +## Properties + +Name | Type | Description | Notes +------------ | ------------- | ------------- | ------------- +**add** | **Array<string>** | Linux capabilities to add | [optional] [default to undefined] +**drop** | **Array<string>** | Linux capabilities to drop | [optional] [default to undefined] + +[[Back to Model list]](../README.md#documentation-for-models) diff --git a/apps/libs/runner-api-client/src/docs/CreateBoxWithCapabilitiesDTO.md b/apps/libs/runner-api-client/src/docs/CreateBoxWithCapabilitiesDTO.md new file mode 100644 index 000000000..f13208425 --- /dev/null +++ b/apps/libs/runner-api-client/src/docs/CreateBoxWithCapabilitiesDTO.md @@ -0,0 +1,60 @@ +# CreateBoxWithCapabilitiesDTO + + +## Properties + +Name | Type | Description | Notes +------------ | ------------- | ------------- | ------------- +**advanced** | [**AdvancedBoxOptionsDTO**](AdvancedBoxOptionsDTO.md) | Advanced box configuration | [default to undefined] +**authToken** | **string** | | [optional] [default to undefined] +**cpuQuota** | **number** | | [optional] [default to undefined] +**entrypoint** | **Array<string>** | | [optional] [default to undefined] +**env** | **{ [key: string]: string; }** | | [optional] [default to undefined] +**fromVolumeId** | **string** | | [optional] [default to undefined] +**gpuQuota** | **number** | | [optional] [default to undefined] +**id** | **string** | | [default to undefined] +**image** | **string** | | [default to undefined] +**memoryQuota** | **number** | | [optional] [default to undefined] +**metadata** | **{ [key: string]: string; }** | | [optional] [default to undefined] +**networkAllowList** | **string** | | [optional] [default to undefined] +**networkBlockAll** | **boolean** | | [optional] [default to undefined] +**organizationId** | **string** | Nullable for backward compatibility | [optional] [default to undefined] +**osUser** | **string** | | [default to undefined] +**otelEndpoint** | **string** | | [optional] [default to undefined] +**regionId** | **string** | | [optional] [default to undefined] +**registry** | [**RegistryDTO**](RegistryDTO.md) | | [optional] [default to undefined] +**skipStart** | **boolean** | | [optional] [default to undefined] +**storageQuota** | **number** | | [optional] [default to undefined] +**volumes** | [**Array<DtoVolumeDTO>**](DtoVolumeDTO.md) | | [optional] [default to undefined] + +## Example + +```typescript +import { CreateBoxWithCapabilitiesDTO } from './api'; + +const instance: CreateBoxWithCapabilitiesDTO = { + advanced, + authToken, + cpuQuota, + entrypoint, + env, + fromVolumeId, + gpuQuota, + id, + image, + memoryQuota, + metadata, + networkAllowList, + networkBlockAll, + organizationId, + osUser, + otelEndpoint, + regionId, + registry, + skipStart, + storageQuota, + volumes, +}; +``` + +[[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md) diff --git a/apps/libs/runner-api-client/src/docs/RecoverBoxWithCapabilitiesDTO.md b/apps/libs/runner-api-client/src/docs/RecoverBoxWithCapabilitiesDTO.md new file mode 100644 index 000000000..b156d1a27 --- /dev/null +++ b/apps/libs/runner-api-client/src/docs/RecoverBoxWithCapabilitiesDTO.md @@ -0,0 +1,42 @@ +# RecoverBoxWithCapabilitiesDTO + + +## Properties + +Name | Type | Description | Notes +------------ | ------------- | ------------- | ------------- +**advanced** | [**AdvancedBoxOptionsDTO**](AdvancedBoxOptionsDTO.md) | Advanced box configuration | [default to undefined] +**cpuQuota** | **number** | | [optional] [default to undefined] +**env** | **{ [key: string]: string; }** | | [optional] [default to undefined] +**errorReason** | **string** | | [default to undefined] +**fromVolumeId** | **string** | | [optional] [default to undefined] +**gpuQuota** | **number** | | [optional] [default to undefined] +**memoryQuota** | **number** | | [optional] [default to undefined] +**networkAllowList** | **string** | | [optional] [default to undefined] +**networkBlockAll** | **boolean** | | [optional] [default to undefined] +**osUser** | **string** | | [default to undefined] +**storageQuota** | **number** | | [optional] [default to undefined] +**volumes** | [**Array<DtoVolumeDTO>**](DtoVolumeDTO.md) | | [optional] [default to undefined] + +## Example + +```typescript +import { RecoverBoxWithCapabilitiesDTO } from './api'; + +const instance: RecoverBoxWithCapabilitiesDTO = { + advanced, + cpuQuota, + env, + errorReason, + fromVolumeId, + gpuQuota, + memoryQuota, + networkAllowList, + networkBlockAll, + osUser, + storageQuota, + volumes, +}; +``` + +[[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md) diff --git a/apps/libs/runner-api-client/src/docs/RunnerInfoResponseDTO.md b/apps/libs/runner-api-client/src/docs/RunnerInfoResponseDTO.md index d74bcf23f..8fe64c3c8 100644 --- a/apps/libs/runner-api-client/src/docs/RunnerInfoResponseDTO.md +++ b/apps/libs/runner-api-client/src/docs/RunnerInfoResponseDTO.md @@ -6,6 +6,7 @@ Name | Type | Description | Notes ------------ | ------------- | ------------- | ------------- **appVersion** | **string** | | [optional] [default to undefined] +**features** | **Array<string>** | | [optional] [default to undefined] **metrics** | [**RunnerMetrics**](RunnerMetrics.md) | | [optional] [default to undefined] **serviceHealth** | [**Array<RunnerServiceInfo>**](RunnerServiceInfo.md) | | [optional] [default to undefined] @@ -16,6 +17,7 @@ import { RunnerInfoResponseDTO } from './api'; const instance: RunnerInfoResponseDTO = { appVersion, + features, metrics, serviceHealth, }; diff --git a/apps/libs/runner-api-client/src/models/advanced-box-options-dto.ts b/apps/libs/runner-api-client/src/models/advanced-box-options-dto.ts new file mode 100644 index 000000000..9b8d67d8f --- /dev/null +++ b/apps/libs/runner-api-client/src/models/advanced-box-options-dto.ts @@ -0,0 +1,15 @@ +/* tslint:disable */ +/* eslint-disable */ +/** + * BoxLite Runner API + * BoxLite Runner API + * + * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). + * Do not edit the class manually. + */ + +import type { ContainerCapabilitiesDTO } from './container-capabilities-dto'; + +export interface AdvancedBoxOptionsDTO { + 'capabilities': ContainerCapabilitiesDTO; +} diff --git a/apps/libs/runner-api-client/src/models/container-capabilities-dto.ts b/apps/libs/runner-api-client/src/models/container-capabilities-dto.ts new file mode 100644 index 000000000..401d902c5 --- /dev/null +++ b/apps/libs/runner-api-client/src/models/container-capabilities-dto.ts @@ -0,0 +1,14 @@ +/* tslint:disable */ +/* eslint-disable */ +/** + * BoxLite Runner API + * BoxLite Runner API + * + * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). + * Do not edit the class manually. + */ + +export interface ContainerCapabilitiesDTO { + 'add'?: Array; + 'drop'?: Array; +} diff --git a/apps/libs/runner-api-client/src/models/create-box-with-capabilities-dto.ts b/apps/libs/runner-api-client/src/models/create-box-with-capabilities-dto.ts new file mode 100644 index 000000000..d0d38b535 --- /dev/null +++ b/apps/libs/runner-api-client/src/models/create-box-with-capabilities-dto.ts @@ -0,0 +1,51 @@ +/* tslint:disable */ +/* eslint-disable */ +/** + * BoxLite Runner API + * BoxLite Runner API + * + * The version of the OpenAPI document: v0.0.0-dev + * + * + * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). + * https://openapi-generator.tech + * Do not edit the class manually. + */ + + +// May contain unused imports in some cases +// @ts-ignore +import type { AdvancedBoxOptionsDTO } from './advanced-box-options-dto'; +// May contain unused imports in some cases +// @ts-ignore +import type { DtoVolumeDTO } from './dto-volume-dto'; +// May contain unused imports in some cases +// @ts-ignore +import type { RegistryDTO } from './registry-dto'; + +export interface CreateBoxWithCapabilitiesDTO { + 'advanced': AdvancedBoxOptionsDTO; + 'authToken'?: string; + 'cpuQuota'?: number; + 'entrypoint'?: Array; + 'env'?: { [key: string]: string; }; + 'fromVolumeId'?: string; + 'gpuQuota'?: number; + 'id': string; + 'image': string; + 'memoryQuota'?: number; + 'metadata'?: { [key: string]: string; }; + 'networkAllowList'?: string; + 'networkBlockAll'?: boolean; + /** + * Nullable for backward compatibility + */ + 'organizationId'?: string; + 'osUser': string; + 'otelEndpoint'?: string; + 'regionId'?: string; + 'registry'?: RegistryDTO; + 'skipStart'?: boolean; + 'storageQuota'?: number; + 'volumes'?: Array; +} diff --git a/apps/libs/runner-api-client/src/models/index.ts b/apps/libs/runner-api-client/src/models/index.ts index daace8247..4d1f184bf 100644 --- a/apps/libs/runner-api-client/src/models/index.ts +++ b/apps/libs/runner-api-client/src/models/index.ts @@ -1,7 +1,10 @@ +export * from './advanced-box-options-dto'; export * from './box-info-response'; export * from './build-snapshot-request-dto'; export * from './create-backup-dto'; export * from './create-box-dto'; +export * from './create-box-with-capabilities-dto'; +export * from './container-capabilities-dto'; export * from './dto-volume-dto'; export * from './enums-backup-state'; export * from './enums-box-state'; @@ -11,6 +14,7 @@ export * from './is-recoverable-dto'; export * from './is-recoverable-response'; export * from './pull-snapshot-request-dto'; export * from './recover-box-dto'; +export * from './recover-box-with-capabilities-dto'; export * from './registry-dto'; export * from './runner-info-response-dto'; export * from './runner-metrics'; diff --git a/apps/libs/runner-api-client/src/models/recover-box-with-capabilities-dto.ts b/apps/libs/runner-api-client/src/models/recover-box-with-capabilities-dto.ts new file mode 100644 index 000000000..1e0b5b1f1 --- /dev/null +++ b/apps/libs/runner-api-client/src/models/recover-box-with-capabilities-dto.ts @@ -0,0 +1,36 @@ +/* tslint:disable */ +/* eslint-disable */ +/** + * BoxLite Runner API + * BoxLite Runner API + * + * The version of the OpenAPI document: v0.0.0-dev + * + * + * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). + * https://openapi-generator.tech + * Do not edit the class manually. + */ + + +// May contain unused imports in some cases +// @ts-ignore +import type { AdvancedBoxOptionsDTO } from './advanced-box-options-dto'; +// May contain unused imports in some cases +// @ts-ignore +import type { DtoVolumeDTO } from './dto-volume-dto'; + +export interface RecoverBoxWithCapabilitiesDTO { + 'advanced': AdvancedBoxOptionsDTO; + 'cpuQuota'?: number; + 'env'?: { [key: string]: string; }; + 'errorReason': string; + 'fromVolumeId'?: string; + 'gpuQuota'?: number; + 'memoryQuota'?: number; + 'networkAllowList'?: string; + 'networkBlockAll'?: boolean; + 'osUser': string; + 'storageQuota'?: number; + 'volumes'?: Array; +} diff --git a/apps/libs/runner-api-client/src/models/runner-info-response-dto.ts b/apps/libs/runner-api-client/src/models/runner-info-response-dto.ts index b866561b3..1ad71f658 100644 --- a/apps/libs/runner-api-client/src/models/runner-info-response-dto.ts +++ b/apps/libs/runner-api-client/src/models/runner-info-response-dto.ts @@ -22,6 +22,7 @@ import type { RunnerServiceInfo } from './runner-service-info'; export interface RunnerInfoResponseDTO { 'appVersion'?: string; + 'features'?: Array; 'metrics'?: RunnerMetrics; 'serviceHealth'?: Array; } diff --git a/apps/runner/internal/features.go b/apps/runner/internal/features.go new file mode 100644 index 000000000..31aeb7bc5 --- /dev/null +++ b/apps/runner/internal/features.go @@ -0,0 +1,8 @@ +// Copyright 2025 BoxLite AI +// SPDX-License-Identifier: AGPL-3.0 + +package internal + +// FeatureLinuxCapabilitiesV2 advertises support for the nested +// advanced.capabilities create policy. +const FeatureLinuxCapabilitiesV2 = "linux-capabilities-v2" diff --git a/apps/runner/pkg/api/controllers/box.go b/apps/runner/pkg/api/controllers/box.go index e0496e7d7..2917cae5d 100644 --- a/apps/runner/pkg/api/controllers/box.go +++ b/apps/runner/pkg/api/controllers/box.go @@ -5,6 +5,9 @@ package controllers import ( + "encoding/json" + "errors" + "io" "net/http" "github.com/boxlite-ai/runner/pkg/api/dto" @@ -12,10 +15,63 @@ import ( "github.com/boxlite-ai/runner/pkg/models/enums" "github.com/boxlite-ai/runner/pkg/runner" "github.com/gin-gonic/gin" + "github.com/gin-gonic/gin/binding" common_errors "github.com/boxlite-ai/common-go/pkg/errors" ) +type legacyCreateBoxRequest struct { + dto.CreateBoxDTO + Advanced json.RawMessage `json:"advanced"` + // Retain fail-closed detection for requests produced by the short-lived + // flat capability contract during mixed-version rollouts. + CapAdd json.RawMessage `json:"capAdd"` + CapDrop json.RawMessage `json:"capDrop"` + CapAddSnake json.RawMessage `json:"cap_add"` + CapDropSnake json.RawMessage `json:"cap_drop"` +} + +type legacyRecoverBoxRequest struct { + dto.RecoverBoxDTO + Advanced json.RawMessage `json:"advanced"` + // Retain fail-closed detection for requests produced by the short-lived + // flat capability contract during mixed-version rollouts. + CapAdd json.RawMessage `json:"capAdd"` + CapDrop json.RawMessage `json:"capDrop"` + CapAddSnake json.RawMessage `json:"cap_add"` + CapDropSnake json.RawMessage `json:"cap_drop"` +} + +func hasCapabilityPolicyFields(fields ...json.RawMessage) bool { + for _, field := range fields { + if field != nil { + return true + } + } + return false +} + +func bindStrictJSON(ctx *gin.Context, target any) error { + decoder := json.NewDecoder(ctx.Request.Body) + decoder.DisallowUnknownFields() + if err := decoder.Decode(target); err != nil { + return err + } + + var extra any + if err := decoder.Decode(&extra); err != io.EOF { + if err == nil { + return errors.New("request body must contain a single JSON object") + } + return err + } + + if binding.Validator == nil { + return nil + } + return binding.Validator.ValidateStruct(target) +} + // Create godoc // // @Tags box @@ -33,20 +89,62 @@ import ( // // @id Create func Create(ctx *gin.Context) { - var createBoxDto dto.CreateBoxDTO - err := ctx.ShouldBindJSON(&createBoxDto) + var request legacyCreateBoxRequest + err := ctx.ShouldBindJSON(&request) if err != nil { ctx.Error(common_errors.NewInvalidBodyRequestError(err)) return } + if hasCapabilityPolicyFields( + request.Advanced, + request.CapAdd, + request.CapDrop, + request.CapAddSnake, + request.CapDropSnake, + ) { + ctx.Error(common_errors.NewInvalidBodyRequestError(errors.New("advanced capability policy requires POST /boxes/strict"))) + return + } + createBox(ctx, request.CreateBoxDTO) +} - runner, err := runner.GetInstance(nil) +// CreateWithCapabilities godoc +// +// @Tags box +// @Summary Create a box with a capability policy +// @Description Fail-closed create contract for capability-bearing requests +// @Param box body dto.CreateBoxWithCapabilitiesDTO true "Create box with capabilities" +// @Produce json +// @Success 201 {object} dto.StartBoxResponse +// @Failure 400 {object} common_errors.ErrorResponse +// @Failure 401 {object} common_errors.ErrorResponse +// @Failure 404 {object} common_errors.ErrorResponse +// @Failure 409 {object} common_errors.ErrorResponse +// @Failure 500 {object} common_errors.ErrorResponse +// @Router /boxes/strict [post] +// +// @id CreateWithCapabilities +func CreateWithCapabilities(ctx *gin.Context) { + var request dto.CreateBoxWithCapabilitiesDTO + if err := bindStrictJSON(ctx, &request); err != nil { + ctx.Error(common_errors.NewInvalidBodyRequestError(err)) + return + } + if !request.HasCapabilityPolicy() { + ctx.Error(common_errors.NewInvalidBodyRequestError(errors.New("advanced.capabilities.add or advanced.capabilities.drop is required"))) + return + } + createBox(ctx, request.AsCreateBoxDTO()) +} + +func createBox(ctx *gin.Context, createBoxDto dto.CreateBoxDTO) { + runnerInstance, err := runner.GetInstance(nil) if err != nil { ctx.Error(err) return } - _, daemonVersion, err := runner.Boxlite.Create(ctx.Request.Context(), createBoxDto) + _, daemonVersion, err := runnerInstance.Boxlite.Create(ctx.Request.Context(), createBoxDto) if err != nil { common.ContainerOperationCount.WithLabelValues("create", string(common.PrometheusOperationStatusFailure)).Inc() ctx.Error(err) @@ -297,21 +395,65 @@ type BoxInfoResponse struct { // // @id Recover func Recover(ctx *gin.Context) { - var recoverDto dto.RecoverBoxDTO - err := ctx.ShouldBindJSON(&recoverDto) + var request legacyRecoverBoxRequest + err := ctx.ShouldBindJSON(&request) if err != nil { ctx.Error(common_errors.NewInvalidBodyRequestError(err)) return } + if hasCapabilityPolicyFields( + request.Advanced, + request.CapAdd, + request.CapDrop, + request.CapAddSnake, + request.CapDropSnake, + ) { + ctx.Error(common_errors.NewInvalidBodyRequestError(errors.New("advanced capability policy requires the strict recovery endpoint"))) + return + } + recoverBox(ctx, request.RecoverBoxDTO) +} +// RecoverWithCapabilities godoc +// +// @Summary Recover a box with a capability policy +// @Description Fail-closed recovery contract for capability-bearing requests +// @Tags box +// @Accept json +// @Produce json +// @Param boxId path string true "Box ID" +// @Param recovery body dto.RecoverBoxWithCapabilitiesDTO true "Recovery parameters with capabilities" +// @Success 200 {string} string "Box recovered" +// @Failure 400 {object} common_errors.ErrorResponse +// @Failure 401 {object} common_errors.ErrorResponse +// @Failure 404 {object} common_errors.ErrorResponse +// @Failure 409 {object} common_errors.ErrorResponse +// @Failure 500 {object} common_errors.ErrorResponse +// @Router /boxes/{boxId}/recover/strict [post] +// +// @id RecoverWithCapabilities +func RecoverWithCapabilities(ctx *gin.Context) { + var request dto.RecoverBoxWithCapabilitiesDTO + if err := bindStrictJSON(ctx, &request); err != nil { + ctx.Error(common_errors.NewInvalidBodyRequestError(err)) + return + } + if !request.HasCapabilityPolicy() { + ctx.Error(common_errors.NewInvalidBodyRequestError(errors.New("advanced.capabilities.add or advanced.capabilities.drop is required"))) + return + } + recoverBox(ctx, request.AsRecoverBoxDTO()) +} + +func recoverBox(ctx *gin.Context, recoverDto dto.RecoverBoxDTO) { boxId := ctx.Param("boxId") - runner, err := runner.GetInstance(nil) + runnerInstance, err := runner.GetInstance(nil) if err != nil { ctx.Error(err) return } - err = runner.Boxlite.RecoverBox(ctx.Request.Context(), boxId, recoverDto) + err = runnerInstance.Boxlite.RecoverBox(ctx.Request.Context(), boxId, recoverDto) if err != nil { ctx.Error(err) return diff --git a/apps/runner/pkg/api/controllers/box_capabilities_test.go b/apps/runner/pkg/api/controllers/box_capabilities_test.go new file mode 100644 index 000000000..65c2055d3 --- /dev/null +++ b/apps/runner/pkg/api/controllers/box_capabilities_test.go @@ -0,0 +1,231 @@ +// Copyright 2026 BoxLite AI +// SPDX-License-Identifier: AGPL-3.0-only + +package controllers + +import ( + "net/http/httptest" + "strings" + "testing" + + "github.com/gin-gonic/gin" +) + +func capabilityRequestContext(t *testing.T, path string, payload string) *gin.Context { + t.Helper() + recorder := httptest.NewRecorder() + ctx, _ := gin.CreateTestContext(recorder) + ctx.Request = httptest.NewRequest("POST", path, strings.NewReader(payload)) + ctx.Request.Header.Set("Content-Type", "application/json") + ctx.Params = gin.Params{{Key: "boxId", Value: "box-1"}} + return ctx +} + +func TestLegacyHTTPContractsRejectCapabilityFields(t *testing.T) { + tests := []struct { + name string + path string + payload string + handler gin.HandlerFunc + }{ + { + name: "create", + path: "/boxes", + payload: `{"id":"box-1","image":"alpine:latest","osUser":"boxlite","advanced":{"capabilities":{"add":["SYS_ADMIN"]}}}`, + handler: Create, + }, + { + name: "recover", + path: "/boxes/box-1/recover", + payload: `{"osUser":"boxlite","errorReason":"retry","advanced":{"capabilities":{"drop":["NET_RAW"]}}}`, + handler: Recover, + }, + { + name: "create empty advanced field", + path: "/boxes", + payload: `{"id":"box-1","image":"alpine:latest","osUser":"boxlite","advanced":{}}`, + handler: Create, + }, + { + name: "recover null advanced field", + path: "/boxes/box-1/recover", + payload: `{"osUser":"boxlite","errorReason":"retry","advanced":null}`, + handler: Recover, + }, + { + name: "create empty capabilities field", + path: "/boxes", + payload: `{"id":"box-1","image":"alpine:latest","osUser":"boxlite","advanced":{"capabilities":{}}}`, + handler: Create, + }, + { + name: "recover null capabilities field", + path: "/boxes/box-1/recover", + payload: `{"osUser":"boxlite","errorReason":"retry","advanced":{"capabilities":null}}`, + handler: Recover, + }, + { + name: "create old flat policy field", + path: "/boxes", + payload: `{"id":"box-1","image":"alpine:latest","osUser":"boxlite","capAdd":["SYS_ADMIN"]}`, + handler: Create, + }, + { + name: "create snake case flat policy field", + path: "/boxes", + payload: `{"id":"box-1","image":"alpine:latest","osUser":"boxlite","cap_add":["SYS_ADMIN"]}`, + handler: Create, + }, + { + name: "recover snake case flat policy field", + path: "/boxes/box-1/recover", + payload: `{"osUser":"boxlite","errorReason":"retry","cap_drop":["NET_RAW"]}`, + handler: Recover, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + ctx := capabilityRequestContext(t, test.path, test.payload) + test.handler(ctx) + + if len(ctx.Errors) == 0 || !strings.Contains(ctx.Errors.Last().Error(), "capability") { + t.Fatalf("expected explicit capability contract error, got %v", ctx.Errors) + } + }) + } +} + +func TestStrictHTTPContractsAcceptOneSidedCapabilityPolicies(t *testing.T) { + tests := []struct { + name string + path string + payload string + handler gin.HandlerFunc + }{ + { + name: "create add only", + path: "/boxes/strict", + payload: `{"id":"box-1","image":"alpine:latest","osUser":"boxlite","advanced":{"capabilities":{"add":["SYS_ADMIN"]}}}`, + handler: CreateWithCapabilities, + }, + { + name: "create drop only", + path: "/boxes/strict", + payload: `{"id":"box-1","image":"alpine:latest","osUser":"boxlite","advanced":{"capabilities":{"drop":["NET_RAW"]}}}`, + handler: CreateWithCapabilities, + }, + { + name: "recover add only", + path: "/boxes/box-1/recover/strict", + payload: `{"osUser":"boxlite","errorReason":"retry","advanced":{"capabilities":{"add":["SYS_ADMIN"]}}}`, + handler: RecoverWithCapabilities, + }, + { + name: "recover drop only", + path: "/boxes/box-1/recover/strict", + payload: `{"osUser":"boxlite","errorReason":"retry","advanced":{"capabilities":{"drop":["NET_RAW"]}}}`, + handler: RecoverWithCapabilities, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + ctx := capabilityRequestContext(t, test.path, test.payload) + test.handler(ctx) + + if len(ctx.Errors) > 0 && strings.Contains(ctx.Errors.Last().Error(), "invalid request body") { + t.Fatalf("one-sided capability policy was rejected: %v", ctx.Errors.Last()) + } + }) + } +} + +func TestStrictHTTPContractsRejectUnknownFields(t *testing.T) { + tests := []struct { + name string + path string + payload string + handler gin.HandlerFunc + }{ + { + name: "create top-level", + path: "/boxes/strict", + payload: `{"id":"box-1","image":"alpine:latest","osUser":"boxlite","advanced":{"capabilities":{"add":["SYS_ADMIN"]}},"futureSecurityOption":true}`, + handler: CreateWithCapabilities, + }, + { + name: "create advanced", + path: "/boxes/strict", + payload: `{"id":"box-1","image":"alpine:latest","osUser":"boxlite","advanced":{"capabilities":{"add":["SYS_ADMIN"]},"futureSecurityOption":true}}`, + handler: CreateWithCapabilities, + }, + { + name: "recover capabilities", + path: "/boxes/box-1/recover/strict", + payload: `{"osUser":"boxlite","errorReason":"retry","advanced":{"capabilities":{"drop":["NET_RAW"],"futureCapabilityOption":true}}}`, + handler: RecoverWithCapabilities, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + ctx := capabilityRequestContext(t, test.path, test.payload) + test.handler(ctx) + + if len(ctx.Errors) == 0 || !strings.Contains(ctx.Errors.Last().Error(), "unknown field") { + t.Fatalf("expected unknown-field rejection, got %v", ctx.Errors) + } + }) + } +} + +func TestStrictHTTPContractsRejectNullCapabilityFields(t *testing.T) { + tests := []struct { + name string + path string + payload string + handler gin.HandlerFunc + want string + }{ + { + name: "create null advanced", + path: "/boxes/strict", + payload: `{"id":"box-1","image":"alpine:latest","osUser":"boxlite","advanced":null}`, + handler: CreateWithCapabilities, + want: "advanced", + }, + { + name: "recover null capabilities", + path: "/boxes/box-1/recover/strict", + payload: `{"osUser":"boxlite","errorReason":"retry","advanced":{"capabilities":null}}`, + handler: RecoverWithCapabilities, + want: "capabilities", + }, + { + name: "create null add", + path: "/boxes/strict", + payload: `{"id":"box-1","image":"alpine:latest","osUser":"boxlite","advanced":{"capabilities":{"add":null,"drop":["NET_RAW"]}}}`, + handler: CreateWithCapabilities, + want: "add must not be null", + }, + { + name: "recover null drop", + path: "/boxes/box-1/recover/strict", + payload: `{"osUser":"boxlite","errorReason":"retry","advanced":{"capabilities":{"add":["SYS_ADMIN"],"drop":null}}}`, + handler: RecoverWithCapabilities, + want: "drop must not be null", + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + ctx := capabilityRequestContext(t, test.path, test.payload) + test.handler(ctx) + + if len(ctx.Errors) == 0 || !strings.Contains(ctx.Errors.Last().Error(), test.want) { + t.Fatalf("expected null-field rejection, got %v", ctx.Errors) + } + }) + } +} diff --git a/apps/runner/pkg/api/controllers/info.go b/apps/runner/pkg/api/controllers/info.go index f33d7f8c2..ab148420e 100644 --- a/apps/runner/pkg/api/controllers/info.go +++ b/apps/runner/pkg/api/controllers/info.go @@ -51,6 +51,7 @@ func RunnerInfo(ctx *gin.Context) { CurrentStartedBoxes: int64(metrics.StartedBoxCount), }, AppVersion: internal.Version, + Features: []string{internal.FeatureLinuxCapabilitiesV2}, } ctx.JSON(http.StatusOK, response) diff --git a/apps/runner/pkg/api/docs/docs.go b/apps/runner/pkg/api/docs/docs.go index 20cde8aa3..12b1e3326 100644 --- a/apps/runner/pkg/api/docs/docs.go +++ b/apps/runner/pkg/api/docs/docs.go @@ -98,6 +98,68 @@ const docTemplate = `{ } } }, + "/boxes/strict": { + "post": { + "description": "Fail-closed create contract for capability-bearing requests", + "produces": [ + "application/json" + ], + "tags": [ + "box" + ], + "summary": "Create a box with a capability policy", + "operationId": "CreateWithCapabilities", + "parameters": [ + { + "description": "Create box with capabilities", + "name": "box", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/CreateBoxWithCapabilitiesDTO" + } + } + ], + "responses": { + "201": { + "description": "Created", + "schema": { + "$ref": "#/definitions/StartBoxResponse" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/ErrorResponse" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/ErrorResponse" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/ErrorResponse" + } + }, + "409": { + "description": "Conflict", + "schema": { + "$ref": "#/definitions/ErrorResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/ErrorResponse" + } + } + } + } + }, "/boxes/{boxId}": { "get": { "description": "Get box info", @@ -476,6 +538,78 @@ const docTemplate = `{ } } }, + "/boxes/{boxId}/recover/strict": { + "post": { + "description": "Fail-closed recovery contract for capability-bearing requests", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "box" + ], + "summary": "Recover a box with a capability policy", + "operationId": "RecoverWithCapabilities", + "parameters": [ + { + "type": "string", + "description": "Box ID", + "name": "boxId", + "in": "path", + "required": true + }, + { + "description": "Recovery parameters with capabilities", + "name": "recovery", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/RecoverBoxWithCapabilitiesDTO" + } + } + ], + "responses": { + "200": { + "description": "Box recovered", + "schema": { + "type": "string" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/ErrorResponse" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/ErrorResponse" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/ErrorResponse" + } + }, + "409": { + "description": "Conflict", + "schema": { + "$ref": "#/definitions/ErrorResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/ErrorResponse" + } + } + } + } + }, "/boxes/{boxId}/start": { "post": { "description": "Start box", @@ -1156,6 +1290,18 @@ const docTemplate = `{ } }, "definitions": { + "AdvancedBoxOptionsDTO": { + "type": "object", + "required": [ + "capabilities" + ], + "additionalProperties": false, + "properties": { + "capabilities": { + "$ref": "#/definitions/ContainerCapabilitiesDTO" + } + } + }, "BoxInfoResponse": { "type": "object", "properties": { @@ -1315,6 +1461,116 @@ const docTemplate = `{ } } }, + "CreateBoxWithCapabilitiesDTO": { + "type": "object", + "required": [ + "advanced", + "id", + "image", + "osUser" + ], + "additionalProperties": false, + "properties": { + "advanced": { + "$ref": "#/definitions/AdvancedBoxOptionsDTO" + }, + "authToken": { + "type": "string" + }, + "cpuQuota": { + "type": "integer", + "minimum": 1 + }, + "entrypoint": { + "type": "array", + "items": { + "type": "string" + } + }, + "env": { + "type": "object", + "additionalProperties": { + "type": "string" + } + }, + "fromVolumeId": { + "type": "string" + }, + "gpuQuota": { + "type": "integer", + "minimum": 0 + }, + "id": { + "type": "string" + }, + "image": { + "type": "string" + }, + "memoryQuota": { + "type": "integer", + "minimum": 1 + }, + "metadata": { + "type": "object", + "additionalProperties": { + "type": "string" + } + }, + "networkAllowList": { + "type": "string" + }, + "networkBlockAll": { + "type": "boolean" + }, + "organizationId": { + "description": "Nullable for backward compatibility", + "type": "string" + }, + "osUser": { + "type": "string" + }, + "otelEndpoint": { + "type": "string" + }, + "regionId": { + "type": "string" + }, + "registry": { + "$ref": "#/definitions/RegistryDTO" + }, + "skipStart": { + "type": "boolean" + }, + "storageQuota": { + "type": "integer", + "minimum": 1 + }, + "volumes": { + "type": "array", + "items": { + "$ref": "#/definitions/dto.VolumeDTO" + } + } + } + }, + "ContainerCapabilitiesDTO": { + "type": "object", + "additionalProperties": false, + "properties": { + "add": { + "type": "array", + "items": { + "type": "string" + } + }, + "drop": { + "type": "array", + "items": { + "type": "string" + } + } + } + }, "ErrorResponse": { "description": "Error response", "type": "object", @@ -1466,6 +1722,63 @@ const docTemplate = `{ } } }, + "RecoverBoxWithCapabilitiesDTO": { + "type": "object", + "required": [ + "advanced", + "errorReason", + "osUser" + ], + "additionalProperties": false, + "properties": { + "advanced": { + "$ref": "#/definitions/AdvancedBoxOptionsDTO" + }, + "cpuQuota": { + "type": "integer", + "minimum": 1 + }, + "env": { + "type": "object", + "additionalProperties": { + "type": "string" + } + }, + "errorReason": { + "type": "string" + }, + "fromVolumeId": { + "type": "string" + }, + "gpuQuota": { + "type": "integer", + "minimum": 0 + }, + "memoryQuota": { + "type": "integer", + "minimum": 1 + }, + "networkAllowList": { + "type": "string" + }, + "networkBlockAll": { + "type": "boolean" + }, + "osUser": { + "type": "string" + }, + "storageQuota": { + "type": "integer", + "minimum": 1 + }, + "volumes": { + "type": "array", + "items": { + "$ref": "#/definitions/dto.VolumeDTO" + } + } + } + }, "RegistryDTO": { "type": "object", "required": [ @@ -1492,6 +1805,12 @@ const docTemplate = `{ "appVersion": { "type": "string" }, + "features": { + "type": "array", + "items": { + "type": "string" + } + }, "metrics": { "$ref": "#/definitions/RunnerMetrics" }, diff --git a/apps/runner/pkg/api/docs/swagger.json b/apps/runner/pkg/api/docs/swagger.json index 63e16c0dc..6e92a29d9 100644 --- a/apps/runner/pkg/api/docs/swagger.json +++ b/apps/runner/pkg/api/docs/swagger.json @@ -84,6 +84,64 @@ } } }, + "/boxes/strict": { + "post": { + "description": "Fail-closed create contract for capability-bearing requests", + "produces": ["application/json"], + "tags": ["box"], + "summary": "Create a box with a capability policy", + "operationId": "CreateWithCapabilities", + "parameters": [ + { + "description": "Create box with capabilities", + "name": "box", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/CreateBoxWithCapabilitiesDTO" + } + } + ], + "responses": { + "201": { + "description": "Created", + "schema": { + "$ref": "#/definitions/StartBoxResponse" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/ErrorResponse" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/ErrorResponse" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/ErrorResponse" + } + }, + "409": { + "description": "Conflict", + "schema": { + "$ref": "#/definitions/ErrorResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/ErrorResponse" + } + } + } + } + }, "/boxes/{boxId}": { "get": { "description": "Get box info", @@ -434,6 +492,72 @@ } } }, + "/boxes/{boxId}/recover/strict": { + "post": { + "description": "Fail-closed recovery contract for capability-bearing requests", + "consumes": ["application/json"], + "produces": ["application/json"], + "tags": ["box"], + "summary": "Recover a box with a capability policy", + "operationId": "RecoverWithCapabilities", + "parameters": [ + { + "type": "string", + "description": "Box ID", + "name": "boxId", + "in": "path", + "required": true + }, + { + "description": "Recovery parameters with capabilities", + "name": "recovery", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/RecoverBoxWithCapabilitiesDTO" + } + } + ], + "responses": { + "200": { + "description": "Box recovered", + "schema": { + "type": "string" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/ErrorResponse" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/ErrorResponse" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/ErrorResponse" + } + }, + "409": { + "description": "Conflict", + "schema": { + "$ref": "#/definitions/ErrorResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/ErrorResponse" + } + } + } + } + }, "/boxes/{boxId}/start": { "post": { "description": "Start box", @@ -1078,6 +1202,16 @@ } }, "definitions": { + "AdvancedBoxOptionsDTO": { + "type": "object", + "required": ["capabilities"], + "additionalProperties": false, + "properties": { + "capabilities": { + "$ref": "#/definitions/ContainerCapabilitiesDTO" + } + } + }, "BoxInfoResponse": { "type": "object", "properties": { @@ -1227,6 +1361,111 @@ } } }, + "CreateBoxWithCapabilitiesDTO": { + "type": "object", + "required": ["advanced", "id", "image", "osUser"], + "additionalProperties": false, + "properties": { + "advanced": { + "$ref": "#/definitions/AdvancedBoxOptionsDTO" + }, + "authToken": { + "type": "string" + }, + "cpuQuota": { + "type": "integer", + "minimum": 1 + }, + "entrypoint": { + "type": "array", + "items": { + "type": "string" + } + }, + "env": { + "type": "object", + "additionalProperties": { + "type": "string" + } + }, + "fromVolumeId": { + "type": "string" + }, + "gpuQuota": { + "type": "integer", + "minimum": 0 + }, + "id": { + "type": "string" + }, + "image": { + "type": "string" + }, + "memoryQuota": { + "type": "integer", + "minimum": 1 + }, + "metadata": { + "type": "object", + "additionalProperties": { + "type": "string" + } + }, + "networkAllowList": { + "type": "string" + }, + "networkBlockAll": { + "type": "boolean" + }, + "organizationId": { + "description": "Nullable for backward compatibility", + "type": "string" + }, + "osUser": { + "type": "string" + }, + "otelEndpoint": { + "type": "string" + }, + "regionId": { + "type": "string" + }, + "registry": { + "$ref": "#/definitions/RegistryDTO" + }, + "skipStart": { + "type": "boolean" + }, + "storageQuota": { + "type": "integer", + "minimum": 1 + }, + "volumes": { + "type": "array", + "items": { + "$ref": "#/definitions/dto.VolumeDTO" + } + } + } + }, + "ContainerCapabilitiesDTO": { + "type": "object", + "additionalProperties": false, + "properties": { + "add": { + "type": "array", + "items": { + "type": "string" + } + }, + "drop": { + "type": "array", + "items": { + "type": "string" + } + } + } + }, "ErrorResponse": { "description": "Error response", "type": "object", @@ -1364,6 +1603,59 @@ } } }, + "RecoverBoxWithCapabilitiesDTO": { + "type": "object", + "required": ["advanced", "errorReason", "osUser"], + "additionalProperties": false, + "properties": { + "advanced": { + "$ref": "#/definitions/AdvancedBoxOptionsDTO" + }, + "cpuQuota": { + "type": "integer", + "minimum": 1 + }, + "env": { + "type": "object", + "additionalProperties": { + "type": "string" + } + }, + "errorReason": { + "type": "string" + }, + "fromVolumeId": { + "type": "string" + }, + "gpuQuota": { + "type": "integer", + "minimum": 0 + }, + "memoryQuota": { + "type": "integer", + "minimum": 1 + }, + "networkAllowList": { + "type": "string" + }, + "networkBlockAll": { + "type": "boolean" + }, + "osUser": { + "type": "string" + }, + "storageQuota": { + "type": "integer", + "minimum": 1 + }, + "volumes": { + "type": "array", + "items": { + "$ref": "#/definitions/dto.VolumeDTO" + } + } + } + }, "RegistryDTO": { "type": "object", "required": ["url"], @@ -1388,6 +1680,12 @@ "appVersion": { "type": "string" }, + "features": { + "type": "array", + "items": { + "type": "string" + } + }, "metrics": { "$ref": "#/definitions/RunnerMetrics" }, diff --git a/apps/runner/pkg/api/docs/swagger.yaml b/apps/runner/pkg/api/docs/swagger.yaml index a66692434..16442974a 100644 --- a/apps/runner/pkg/api/docs/swagger.yaml +++ b/apps/runner/pkg/api/docs/swagger.yaml @@ -1,4 +1,12 @@ definitions: + AdvancedBoxOptionsDTO: + additionalProperties: false + properties: + capabilities: + $ref: '#/definitions/ContainerCapabilitiesDTO' + required: + - capabilities + type: object BoxInfoResponse: properties: backupError: @@ -107,6 +115,82 @@ definitions: - osUser - image type: object + CreateBoxWithCapabilitiesDTO: + additionalProperties: false + properties: + advanced: + $ref: '#/definitions/AdvancedBoxOptionsDTO' + authToken: + type: string + cpuQuota: + minimum: 1 + type: integer + entrypoint: + items: + type: string + type: array + env: + additionalProperties: + type: string + type: object + fromVolumeId: + type: string + gpuQuota: + minimum: 0 + type: integer + id: + type: string + image: + type: string + memoryQuota: + minimum: 1 + type: integer + metadata: + additionalProperties: + type: string + type: object + networkAllowList: + type: string + networkBlockAll: + type: boolean + organizationId: + description: Nullable for backward compatibility + type: string + osUser: + type: string + otelEndpoint: + type: string + regionId: + type: string + registry: + $ref: '#/definitions/RegistryDTO' + skipStart: + type: boolean + storageQuota: + minimum: 1 + type: integer + volumes: + items: + $ref: '#/definitions/dto.VolumeDTO' + type: array + required: + - advanced + - id + - image + - osUser + type: object + ContainerCapabilitiesDTO: + additionalProperties: false + properties: + add: + items: + type: string + type: array + drop: + items: + type: string + type: array + type: object ErrorResponse: description: Error response properties: @@ -211,6 +295,46 @@ definitions: - errorReason - osUser type: object + RecoverBoxWithCapabilitiesDTO: + additionalProperties: false + properties: + advanced: + $ref: '#/definitions/AdvancedBoxOptionsDTO' + cpuQuota: + minimum: 1 + type: integer + env: + additionalProperties: + type: string + type: object + errorReason: + type: string + fromVolumeId: + type: string + gpuQuota: + minimum: 0 + type: integer + memoryQuota: + minimum: 1 + type: integer + networkAllowList: + type: string + networkBlockAll: + type: boolean + osUser: + type: string + storageQuota: + minimum: 1 + type: integer + volumes: + items: + $ref: '#/definitions/dto.VolumeDTO' + type: array + required: + - advanced + - errorReason + - osUser + type: object RegistryDTO: properties: password: @@ -228,6 +352,10 @@ definitions: properties: appVersion: type: string + features: + items: + type: string + type: array metrics: $ref: '#/definitions/RunnerMetrics' serviceHealth: @@ -452,6 +580,47 @@ paths: summary: Create a box tags: - box + /boxes/strict: + post: + description: Fail-closed create contract for capability-bearing requests + operationId: CreateWithCapabilities + parameters: + - description: Create box with capabilities + in: body + name: box + required: true + schema: + $ref: '#/definitions/CreateBoxWithCapabilitiesDTO' + produces: + - application/json + responses: + '201': + description: Created + schema: + $ref: '#/definitions/StartBoxResponse' + '400': + description: Bad Request + schema: + $ref: '#/definitions/ErrorResponse' + '401': + description: Unauthorized + schema: + $ref: '#/definitions/ErrorResponse' + '404': + description: Not Found + schema: + $ref: '#/definitions/ErrorResponse' + '409': + description: Conflict + schema: + $ref: '#/definitions/ErrorResponse' + '500': + description: Internal Server Error + schema: + $ref: '#/definitions/ErrorResponse' + summary: Create a box with a capability policy + tags: + - box /boxes/{boxId}: get: description: Get box info @@ -704,6 +873,54 @@ paths: summary: Recover box from error state tags: - box + /boxes/{boxId}/recover/strict: + post: + consumes: + - application/json + description: Fail-closed recovery contract for capability-bearing requests + operationId: RecoverWithCapabilities + parameters: + - description: Box ID + in: path + name: boxId + required: true + type: string + - description: Recovery parameters with capabilities + in: body + name: recovery + required: true + schema: + $ref: '#/definitions/RecoverBoxWithCapabilitiesDTO' + produces: + - application/json + responses: + '200': + description: Box recovered + schema: + type: string + '400': + description: Bad Request + schema: + $ref: '#/definitions/ErrorResponse' + '401': + description: Unauthorized + schema: + $ref: '#/definitions/ErrorResponse' + '404': + description: Not Found + schema: + $ref: '#/definitions/ErrorResponse' + '409': + description: Conflict + schema: + $ref: '#/definitions/ErrorResponse' + '500': + description: Internal Server Error + schema: + $ref: '#/definitions/ErrorResponse' + summary: Recover a box with a capability policy + tags: + - box /boxes/{boxId}/start: post: description: Start box diff --git a/apps/runner/pkg/api/dto/box.go b/apps/runner/pkg/api/dto/box.go index d3bb57430..847903320 100644 --- a/apps/runner/pkg/api/dto/box.go +++ b/apps/runner/pkg/api/dto/box.go @@ -4,6 +4,12 @@ package dto +import ( + "bytes" + "encoding/json" + "fmt" +) + type CreateBoxDTO struct { Id string `json:"id" validate:"required"` FromVolumeId string `json:"fromVolumeId,omitempty"` @@ -27,8 +33,102 @@ type CreateBoxDTO struct { // Nullable for backward compatibility OrganizationId *string `json:"organizationId,omitempty"` RegionId *string `json:"regionId,omitempty"` + + // Advanced is execution-only on this legacy wire DTO. Capability-bearing + // requests use CreateBoxWithCapabilitiesDTO so old endpoints cannot silently + // discard policy fields they do not understand. + Advanced *AdvancedBoxOptionsDTO `json:"-" swaggerignore:"true"` } // @name CreateBoxDTO +type CreateBoxWithCapabilitiesDTO struct { + CreateBoxDTO + Advanced *AdvancedBoxOptionsDTO `json:"advanced" validate:"required"` +} // @name CreateBoxWithCapabilitiesDTO + +func (d CreateBoxWithCapabilitiesDTO) HasCapabilityPolicy() bool { + return d.Advanced != nil && d.Advanced.HasCapabilityPolicy() +} + +func (d CreateBoxWithCapabilitiesDTO) AsCreateBoxDTO() CreateBoxDTO { + request := d.CreateBoxDTO + request.Advanced = d.Advanced.Clone() + return request +} + +type AdvancedBoxOptionsDTO struct { + Capabilities *ContainerCapabilitiesDTO `json:"capabilities" validate:"required"` +} // @name AdvancedBoxOptionsDTO + +func (d *AdvancedBoxOptionsDTO) HasCapabilityPolicy() bool { + return d != nil && d.Capabilities != nil && !d.Capabilities.IsEmpty() +} + +func (d *AdvancedBoxOptionsDTO) Clone() *AdvancedBoxOptionsDTO { + if d == nil { + return nil + } + + clone := &AdvancedBoxOptionsDTO{} + if d.Capabilities != nil { + clone.Capabilities = &ContainerCapabilitiesDTO{ + Add: append([]string(nil), d.Capabilities.Add...), + Drop: append([]string(nil), d.Capabilities.Drop...), + } + } + return clone +} + +type ContainerCapabilitiesDTO struct { + Add []string `json:"add,omitempty" validate:"omitempty,dive,required"` + Drop []string `json:"drop,omitempty" validate:"omitempty,dive,required"` +} // @name ContainerCapabilitiesDTO + +func (d *ContainerCapabilitiesDTO) UnmarshalJSON(data []byte) error { + type containerCapabilitiesWire struct { + Add json.RawMessage `json:"add"` + Drop json.RawMessage `json:"drop"` + } + + decoder := json.NewDecoder(bytes.NewReader(data)) + decoder.DisallowUnknownFields() + var wire containerCapabilitiesWire + if err := decoder.Decode(&wire); err != nil { + return err + } + + add, err := decodeCapabilityList("add", wire.Add) + if err != nil { + return err + } + drop, err := decodeCapabilityList("drop", wire.Drop) + if err != nil { + return err + } + + d.Add = add + d.Drop = drop + return nil +} + +func decodeCapabilityList(field string, raw json.RawMessage) ([]string, error) { + if raw == nil { + return nil, nil + } + if bytes.Equal(bytes.TrimSpace(raw), []byte("null")) { + return nil, fmt.Errorf("advanced.capabilities.%s must not be null", field) + } + + var capabilities []string + if err := json.Unmarshal(raw, &capabilities); err != nil { + return nil, fmt.Errorf("decode advanced.capabilities.%s: %w", field, err) + } + return capabilities, nil +} + +func (d *ContainerCapabilitiesDTO) IsEmpty() bool { + return d == nil || (len(d.Add) == 0 && len(d.Drop) == 0) +} + type UpdateNetworkSettingsDTO struct { NetworkBlockAll *bool `json:"networkBlockAll,omitempty"` NetworkAllowList *string `json:"networkAllowList,omitempty"` @@ -47,8 +147,26 @@ type RecoverBoxDTO struct { NetworkBlockAll *bool `json:"networkBlockAll,omitempty"` NetworkAllowList *string `json:"networkAllowList,omitempty"` ErrorReason string `json:"errorReason" validate:"required"` + + // Advanced is populated only after decoding the strict wire DTO. + Advanced *AdvancedBoxOptionsDTO `json:"-" swaggerignore:"true"` } // @name RecoverBoxDTO +type RecoverBoxWithCapabilitiesDTO struct { + RecoverBoxDTO + Advanced *AdvancedBoxOptionsDTO `json:"advanced" validate:"required"` +} // @name RecoverBoxWithCapabilitiesDTO + +func (d RecoverBoxWithCapabilitiesDTO) HasCapabilityPolicy() bool { + return d.Advanced != nil && d.Advanced.HasCapabilityPolicy() +} + +func (d RecoverBoxWithCapabilitiesDTO) AsRecoverBoxDTO() RecoverBoxDTO { + request := d.RecoverBoxDTO + request.Advanced = d.Advanced.Clone() + return request +} + type IsRecoverableDTO struct { ErrorReason string `json:"errorReason" validate:"required"` } // @name IsRecoverableDTO diff --git a/apps/runner/pkg/api/dto/box_capabilities_test.go b/apps/runner/pkg/api/dto/box_capabilities_test.go new file mode 100644 index 000000000..9e599a290 --- /dev/null +++ b/apps/runner/pkg/api/dto/box_capabilities_test.go @@ -0,0 +1,113 @@ +// Copyright 2025 BoxLite AI +// SPDX-License-Identifier: AGPL-3.0 + +package dto + +import ( + "encoding/json" + "reflect" + "testing" +) + +func TestLegacyCreateAndRecoverBoxDTOsDoNotSerializeCapabilities(t *testing.T) { + tests := []struct { + name string + request any + }{ + { + name: "create", + request: CreateBoxDTO{ + Advanced: capabilityTestAdvancedOptions(), + }, + }, + { + name: "recover", + request: RecoverBoxDTO{ + Advanced: capabilityTestAdvancedOptions(), + }, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + encoded, err := json.Marshal(test.request) + if err != nil { + t.Fatalf("marshal legacy DTO: %v", err) + } + + var wire map[string]any + if err := json.Unmarshal(encoded, &wire); err != nil { + t.Fatalf("decode round-tripped payload: %v", err) + } + if _, ok := wire["advanced"]; ok { + t.Fatalf("legacy %s DTO serialized advanced options: %s", test.name, encoded) + } + }) + } +} + +func TestCapabilityCreateAndRecoverBoxDTOsPreserveCapabilities(t *testing.T) { + tests := []struct { + name string + decode func([]byte) ([]byte, error) + }{ + { + name: "create", + decode: func(payload []byte) ([]byte, error) { + var request CreateBoxWithCapabilitiesDTO + if err := json.Unmarshal(payload, &request); err != nil { + return nil, err + } + return json.Marshal(request) + }, + }, + { + name: "recover", + decode: func(payload []byte) ([]byte, error) { + var request RecoverBoxWithCapabilitiesDTO + if err := json.Unmarshal(payload, &request); err != nil { + return nil, err + } + return json.Marshal(request) + }, + }, + } + + payload := []byte(`{"advanced":{"capabilities":{"add":["SYS_ADMIN"],"drop":["NET_RAW"]}}}`) + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + encoded, err := test.decode(payload) + if err != nil { + t.Fatalf("round trip capability payload: %v", err) + } + + var wire map[string]any + if err := json.Unmarshal(encoded, &wire); err != nil { + t.Fatalf("decode round-tripped payload: %v", err) + } + advanced, ok := wire["advanced"].(map[string]any) + if !ok { + t.Fatalf("advanced options lost across %s DTO: %s", test.name, encoded) + } + capabilities, ok := advanced["capabilities"].(map[string]any) + if !ok { + t.Fatalf("capabilities lost across %s DTO: %s", test.name, encoded) + } + if !reflect.DeepEqual(capabilities["add"], []any{"SYS_ADMIN"}) { + t.Fatalf("capabilities.add lost across %s DTO: %s", test.name, encoded) + } + if !reflect.DeepEqual(capabilities["drop"], []any{"NET_RAW"}) { + t.Fatalf("capabilities.drop lost across %s DTO: %s", test.name, encoded) + } + }) + } +} + +func capabilityTestAdvancedOptions() *AdvancedBoxOptionsDTO { + return &AdvancedBoxOptionsDTO{ + Capabilities: &ContainerCapabilitiesDTO{ + Add: []string{"SYS_ADMIN"}, + Drop: []string{"NET_RAW"}, + }, + } +} diff --git a/apps/runner/pkg/api/dto/info.go b/apps/runner/pkg/api/dto/info.go index 0b5feec9e..f06418549 100644 --- a/apps/runner/pkg/api/dto/info.go +++ b/apps/runner/pkg/api/dto/info.go @@ -25,4 +25,5 @@ type RunnerInfoResponseDTO struct { ServiceHealth []*RunnerServiceInfo `json:"serviceHealth,omitempty"` Metrics *RunnerMetrics `json:"metrics,omitempty"` AppVersion string `json:"appVersion"` + Features []string `json:"features,omitempty"` } // @name RunnerInfoResponseDTO diff --git a/apps/runner/pkg/api/server.go b/apps/runner/pkg/api/server.go index f53bfd675..5efeab5de 100644 --- a/apps/runner/pkg/api/server.go +++ b/apps/runner/pkg/api/server.go @@ -132,11 +132,13 @@ func (a *ApiServer) Start(ctx context.Context) error { boxController := protected.Group("/boxes") { boxController.POST("", controllers.Create) + boxController.POST("/strict", controllers.CreateWithCapabilities) boxController.GET("/:boxId", controllers.Info) boxController.POST("/:boxId/destroy", controllers.Destroy) boxController.POST("/:boxId/start", controllers.Start) boxController.POST("/:boxId/stop", controllers.Stop) boxController.POST("/:boxId/recover", controllers.Recover) + boxController.POST("/:boxId/recover/strict", controllers.RecoverWithCapabilities) boxController.POST("/:boxId/is-recoverable", controllers.IsRecoverable) boxController.POST("/:boxId/network-settings", controllers.UpdateNetworkSettings) diff --git a/apps/runner/pkg/boxlite/client.go b/apps/runner/pkg/boxlite/client.go index eed31e44d..fd0d81bc9 100644 --- a/apps/runner/pkg/boxlite/client.go +++ b/apps/runner/pkg/boxlite/client.go @@ -247,6 +247,21 @@ func (c *Client) Create(ctx context.Context, boxDto dto.CreateBoxDTO) (string, s if len(boxDto.Entrypoint) > 0 { opts = append(opts, boxlite.WithEntrypoint(boxDto.Entrypoint...)) } + if boxDto.Advanced != nil && boxDto.Advanced.Capabilities != nil { + capabilities := boxDto.Advanced.Capabilities + advancedOptions, err := boxlite.NewAdvancedBoxOptions() + if err != nil { + return "", "", fmt.Errorf("create advanced box options: %w", err) + } + defer advancedOptions.Close() + if err := advancedOptions.SetCapabilities(boxlite.ContainerCapabilities{ + Add: capabilities.Add, + Drop: capabilities.Drop, + }); err != nil { + return "", "", fmt.Errorf("configure advanced container capabilities: %w", err) + } + opts = append(opts, boxlite.WithAdvancedOptions(advancedOptions)) + } volumeMounts, err := c.getVolumeMounts(ctx, boxDto.Volumes) if err != nil { diff --git a/apps/runner/pkg/boxlite/stubs.go b/apps/runner/pkg/boxlite/stubs.go index c90253390..d7c8269a4 100644 --- a/apps/runner/pkg/boxlite/stubs.go +++ b/apps/runner/pkg/boxlite/stubs.go @@ -27,6 +27,7 @@ func (c *Client) RecoverBox(ctx context.Context, boxId string, recoverDto dto.Re MemoryQuota: recoverDto.MemoryQuota, StorageQuota: recoverDto.StorageQuota, Env: recoverDto.Env, + Advanced: recoverDto.Advanced.Clone(), Volumes: recoverDto.Volumes, NetworkBlockAll: recoverDto.NetworkBlockAll, NetworkAllowList: recoverDto.NetworkAllowList, diff --git a/apps/runner/pkg/common/errors.go b/apps/runner/pkg/common/errors.go index f6e2b66ea..212772b75 100644 --- a/apps/runner/pkg/common/errors.go +++ b/apps/runner/pkg/common/errors.go @@ -5,12 +5,14 @@ package common import ( + "errors" "fmt" "net/http" "strings" "time" "github.com/boxlite-ai/runner/internal/util" + boxlitesdk "github.com/boxlite-ai/boxlite/sdks/go" "github.com/containerd/errdefs" "github.com/gin-gonic/gin" @@ -18,6 +20,18 @@ import ( ) func HandlePossibleDockerError(ctx *gin.Context, err error) common_errors.ErrorResponse { + var boxliteErr *boxlitesdk.Error + if errors.As(err, &boxliteErr) && boxliteErr.Code == boxlitesdk.ErrInvalidArgument { + return common_errors.ErrorResponse{ + StatusCode: http.StatusBadRequest, + Message: fmt.Sprintf("bad request: %s", boxliteErr.Message), + Code: "BAD_REQUEST", + Timestamp: time.Now(), + Path: ctx.Request.URL.Path, + Method: ctx.Request.Method, + } + } + if errdefs.IsUnauthorized(err) || strings.Contains(err.Error(), "unauthorized") { return common_errors.ErrorResponse{ StatusCode: http.StatusUnauthorized, diff --git a/apps/runner/pkg/common/errors_test.go b/apps/runner/pkg/common/errors_test.go new file mode 100644 index 000000000..b6913d322 --- /dev/null +++ b/apps/runner/pkg/common/errors_test.go @@ -0,0 +1,40 @@ +// Copyright 2025 BoxLite AI (originally Daytona Platforms Inc. +// Modified by BoxLite AI, 2025-2026 +// SPDX-License-Identifier: AGPL-3.0 + +package common + +import ( + "fmt" + "net/http" + "net/http/httptest" + "testing" + + boxlitesdk "github.com/boxlite-ai/boxlite/sdks/go" + "github.com/gin-gonic/gin" +) + +func TestHandlePossibleDockerErrorMapsBoxliteInvalidArgument(t *testing.T) { + gin.SetMode(gin.TestMode) + ctx, _ := gin.CreateTestContext(httptest.NewRecorder()) + ctx.Request = httptest.NewRequest(http.MethodPost, "/boxes", nil) + advanced, err := boxlitesdk.NewAdvancedBoxOptions() + if err != nil { + t.Fatalf("create advanced options: %v", err) + } + defer advanced.Close() + err = advanced.SetCapabilities(boxlitesdk.ContainerCapabilities{Add: []string{"NET-ADMIN"}}) + if err == nil { + t.Fatal("malformed capability must be rejected") + } + err = fmt.Errorf("configure advanced container capabilities: %w", err) + + response := HandlePossibleDockerError(ctx, err) + + if response.StatusCode != http.StatusBadRequest { + t.Fatalf("status = %d, want %d", response.StatusCode, http.StatusBadRequest) + } + if response.Code != "BAD_REQUEST" { + t.Fatalf("code = %q, want BAD_REQUEST", response.Code) + } +} diff --git a/apps/runner/pkg/runner/v2/executor/box.go b/apps/runner/pkg/runner/v2/executor/box.go index cfc59828e..59a867d81 100644 --- a/apps/runner/pkg/runner/v2/executor/box.go +++ b/apps/runner/pkg/runner/v2/executor/box.go @@ -7,21 +7,98 @@ package executor import ( "context" + "encoding/json" "fmt" + "io" + "strings" apiclient "github.com/boxlite-ai/boxlite/libs/api-client-go" "github.com/boxlite-ai/runner/pkg/api/dto" "github.com/boxlite-ai/runner/pkg/common" "github.com/containerd/errdefs" + "github.com/go-playground/validator/v10" ) +var strictPayloadValidator = newStrictPayloadValidator() + +func newStrictPayloadValidator() *validator.Validate { + validate := validator.New(validator.WithRequiredStructEnabled()) + validate.SetTagName("validate") + _ = validate.RegisterValidation("optional", func(validator.FieldLevel) bool { + return true + }, true) + return validate +} + +func rejectLegacyCapabilityFields(payload *string, strictJobType apiclient.JobType) error { + if payload == nil || *payload == "" { + return nil + } + + var wireFields map[string]json.RawMessage + if err := json.Unmarshal([]byte(*payload), &wireFields); err != nil { + return nil + } + for field := range wireFields { + if strings.EqualFold(field, "advanced") || + strings.EqualFold(field, "capAdd") || + strings.EqualFold(field, "capDrop") || + strings.EqualFold(field, "cap_add") || + strings.EqualFold(field, "cap_drop") { + return fmt.Errorf("advanced capability policy requires %s job", strictJobType) + } + } + return nil +} + +func parseStrictPayload(payload *string, target any) error { + if payload == nil || *payload == "" { + return fmt.Errorf("payload is required") + } + + decoder := json.NewDecoder(strings.NewReader(*payload)) + decoder.DisallowUnknownFields() + if err := decoder.Decode(target); err != nil { + return err + } + + var extra any + if err := decoder.Decode(&extra); err != io.EOF { + if err == nil { + return fmt.Errorf("payload must contain a single JSON object") + } + return err + } + if err := strictPayloadValidator.Struct(target); err != nil { + return fmt.Errorf("validate payload: %w", err) + } + return nil +} + func (e *Executor) createBox(ctx context.Context, job *apiclient.Job) (any, error) { + if err := rejectLegacyCapabilityFields(job.Payload, apiclient.JOBTYPE_CREATE_BOX_WITH_CAPABILITIES_V2); err != nil { + return nil, err + } var createBoxDto dto.CreateBoxDTO err := e.parsePayload(job.Payload, &createBoxDto) if err != nil { return nil, fmt.Errorf("failed to unmarshal payload: %w", err) } + return e.executeCreateBox(ctx, createBoxDto) +} + +func (e *Executor) createBoxWithCapabilities(ctx context.Context, job *apiclient.Job) (any, error) { + var request dto.CreateBoxWithCapabilitiesDTO + if err := parseStrictPayload(job.Payload, &request); err != nil { + return nil, fmt.Errorf("failed to unmarshal payload: %w", err) + } + if !request.HasCapabilityPolicy() { + return nil, fmt.Errorf("capability create job requires advanced.capabilities.add or advanced.capabilities.drop") + } + return e.executeCreateBox(ctx, request.AsCreateBoxDTO()) +} +func (e *Executor) executeCreateBox(ctx context.Context, createBoxDto dto.CreateBoxDTO) (any, error) { _, daemonVersion, err := e.backend.Create(ctx, createBoxDto) if err != nil { common.ContainerOperationCount.WithLabelValues("create", string(common.PrometheusOperationStatusFailure)).Inc() @@ -89,13 +166,30 @@ func (e *Executor) updateNetworkSettings(ctx context.Context, job *apiclient.Job } func (e *Executor) recoverBox(ctx context.Context, job *apiclient.Job) (any, error) { + if err := rejectLegacyCapabilityFields(job.Payload, apiclient.JOBTYPE_RECOVER_BOX_WITH_CAPABILITIES_V2); err != nil { + return nil, err + } var recoverBoxDto dto.RecoverBoxDTO err := e.parsePayload(job.Payload, &recoverBoxDto) if err != nil { return nil, fmt.Errorf("failed to unmarshal payload: %w", err) } + return e.executeRecoverBox(ctx, job.ResourceId, recoverBoxDto) +} + +func (e *Executor) recoverBoxWithCapabilities(ctx context.Context, job *apiclient.Job) (any, error) { + var request dto.RecoverBoxWithCapabilitiesDTO + if err := parseStrictPayload(job.Payload, &request); err != nil { + return nil, fmt.Errorf("failed to unmarshal payload: %w", err) + } + if !request.HasCapabilityPolicy() { + return nil, fmt.Errorf("capability recovery job requires advanced.capabilities.add or advanced.capabilities.drop") + } + return e.executeRecoverBox(ctx, job.ResourceId, request.AsRecoverBoxDTO()) +} - err = e.backend.RecoverBox(ctx, job.ResourceId, recoverBoxDto) +func (e *Executor) executeRecoverBox(ctx context.Context, boxID string, recoverBoxDto dto.RecoverBoxDTO) (any, error) { + err := e.backend.RecoverBox(ctx, boxID, recoverBoxDto) if err != nil { return nil, common.FormatRecoverableError(err) } diff --git a/apps/runner/pkg/runner/v2/executor/box_capabilities_test.go b/apps/runner/pkg/runner/v2/executor/box_capabilities_test.go new file mode 100644 index 000000000..7319dde1a --- /dev/null +++ b/apps/runner/pkg/runner/v2/executor/box_capabilities_test.go @@ -0,0 +1,328 @@ +// Copyright 2026 BoxLite AI +// SPDX-License-Identifier: AGPL-3.0-only + +package executor + +import ( + "context" + "reflect" + "strings" + "testing" + + apiclient "github.com/boxlite-ai/boxlite/libs/api-client-go" + "github.com/boxlite-ai/runner/pkg/api/dto" + "github.com/boxlite-ai/runner/pkg/backend" +) + +type capabilityCaptureBackend struct { + backend.BoxBackend + createRequest *dto.CreateBoxDTO + recoverRequest *dto.RecoverBoxDTO +} + +func TestLegacyJobsRejectCapabilityFieldsBeforeBackend(t *testing.T) { + tests := []struct { + name string + jobType apiclient.JobType + payload string + }{ + { + name: "create", + jobType: apiclient.JOBTYPE_CREATE_BOX, + payload: `{"id":"box-1","image":"alpine:latest","osUser":"boxlite","advanced":{"capabilities":{"add":["SYS_ADMIN"]}}}`, + }, + { + name: "recover", + jobType: apiclient.JOBTYPE_RECOVER_BOX, + payload: `{"osUser":"boxlite","errorReason":"retry","advanced":{"capabilities":{"drop":["NET_RAW"]}}}`, + }, + { + name: "create empty advanced field", + jobType: apiclient.JOBTYPE_CREATE_BOX, + payload: `{"id":"box-1","image":"alpine:latest","osUser":"boxlite","advanced":{}}`, + }, + { + name: "recover null advanced field", + jobType: apiclient.JOBTYPE_RECOVER_BOX, + payload: `{"osUser":"boxlite","errorReason":"retry","advanced":null}`, + }, + { + name: "create empty capabilities field", + jobType: apiclient.JOBTYPE_CREATE_BOX, + payload: `{"id":"box-1","image":"alpine:latest","osUser":"boxlite","advanced":{"capabilities":{}}}`, + }, + { + name: "recover null capabilities field", + jobType: apiclient.JOBTYPE_RECOVER_BOX, + payload: `{"osUser":"boxlite","errorReason":"retry","advanced":{"capabilities":null}}`, + }, + { + name: "create alternate-case advanced field", + jobType: apiclient.JOBTYPE_CREATE_BOX, + payload: `{"id":"box-1","image":"alpine:latest","osUser":"boxlite","Advanced":{"capabilities":{"add":["SYS_ADMIN"]}}}`, + }, + { + name: "recover old flat policy field", + jobType: apiclient.JOBTYPE_RECOVER_BOX, + payload: `{"osUser":"boxlite","errorReason":"retry","CAPDROP":["NET_RAW"]}`, + }, + { + name: "create snake case flat policy field", + jobType: apiclient.JOBTYPE_CREATE_BOX, + payload: `{"id":"box-1","image":"alpine:latest","osUser":"boxlite","cap_add":["SYS_ADMIN"]}`, + }, + { + name: "recover snake case flat policy field", + jobType: apiclient.JOBTYPE_RECOVER_BOX, + payload: `{"osUser":"boxlite","errorReason":"retry","cap_drop":["NET_RAW"]}`, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + capture := &capabilityCaptureBackend{} + executor := &Executor{backend: capture} + job := apiclient.NewJob( + "job-1", + test.jobType, + apiclient.JOBSTATUS_PENDING, + "box", + "box-1", + "2026-01-01T00:00:00Z", + ) + job.Payload = &test.payload + + _, err := executor.executeJob(context.Background(), job) + if err == nil || !strings.Contains(err.Error(), "capability") { + t.Fatalf("expected explicit capability contract error, got %v", err) + } + if capture.createRequest != nil || capture.recoverRequest != nil { + t.Fatal("legacy capability job reached backend") + } + }) + } +} + +func (b *capabilityCaptureBackend) Create(_ context.Context, request dto.CreateBoxDTO) (string, string, error) { + b.createRequest = &request + return "box-1", "boxlite", nil +} + +func (b *capabilityCaptureBackend) RecoverBox(_ context.Context, _ string, request dto.RecoverBoxDTO) error { + b.recoverRequest = &request + return nil +} + +func TestExecuteCapabilityJobsPreservesPolicy(t *testing.T) { + tests := []struct { + name string + jobType apiclient.JobType + payload string + capability func(*capabilityCaptureBackend) ([]string, []string) + }{ + { + name: "create", + jobType: apiclient.JOBTYPE_CREATE_BOX_WITH_CAPABILITIES_V2, + payload: `{"id":"box-1","image":"alpine:latest","osUser":"boxlite","cpuQuota":1,"gpuQuota":0,"memoryQuota":1,"storageQuota":1,"advanced":{"capabilities":{"add":["SYS_ADMIN"],"drop":["NET_RAW"]}}}`, + capability: func(capture *capabilityCaptureBackend) ([]string, []string) { + if capture.createRequest == nil { + t.Fatal("create backend was not called") + } + if capture.createRequest.Advanced == nil || capture.createRequest.Advanced.Capabilities == nil { + t.Fatal("create backend did not receive advanced capabilities") + } + return capture.createRequest.Advanced.Capabilities.Add, capture.createRequest.Advanced.Capabilities.Drop + }, + }, + { + name: "recover", + jobType: apiclient.JOBTYPE_RECOVER_BOX_WITH_CAPABILITIES_V2, + payload: `{"osUser":"boxlite","cpuQuota":1,"gpuQuota":0,"memoryQuota":1,"storageQuota":1,"errorReason":"retry","advanced":{"capabilities":{"add":["SYS_ADMIN"],"drop":["NET_RAW"]}}}`, + capability: func(capture *capabilityCaptureBackend) ([]string, []string) { + if capture.recoverRequest == nil { + t.Fatal("recover backend was not called") + } + if capture.recoverRequest.Advanced == nil || capture.recoverRequest.Advanced.Capabilities == nil { + t.Fatal("recover backend did not receive advanced capabilities") + } + return capture.recoverRequest.Advanced.Capabilities.Add, capture.recoverRequest.Advanced.Capabilities.Drop + }, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + capture := &capabilityCaptureBackend{} + executor := &Executor{backend: capture} + job := apiclient.NewJob( + "job-1", + test.jobType, + apiclient.JOBSTATUS_PENDING, + "box", + "box-1", + "2026-01-01T00:00:00Z", + ) + job.Payload = &test.payload + + if _, err := executor.executeJob(context.Background(), job); err != nil { + t.Fatalf("execute capability job: %v", err) + } + + capabilityAdd, capabilityDrop := test.capability(capture) + if !reflect.DeepEqual(capabilityAdd, []string{"SYS_ADMIN"}) { + t.Fatalf("unexpected advanced.capabilities.add: %v", capabilityAdd) + } + if !reflect.DeepEqual(capabilityDrop, []string{"NET_RAW"}) { + t.Fatalf("unexpected advanced.capabilities.drop: %v", capabilityDrop) + } + }) + } +} + +func TestCapabilityJobsValidateRequiredFieldsBeforeBackend(t *testing.T) { + tests := []struct { + name string + jobType apiclient.JobType + payload string + }{ + { + name: "create missing id", + jobType: apiclient.JOBTYPE_CREATE_BOX_WITH_CAPABILITIES_V2, + payload: `{"image":"alpine:latest","osUser":"boxlite","cpuQuota":1,"gpuQuota":0,"memoryQuota":1,"storageQuota":1,"advanced":{"capabilities":{"add":["SYS_ADMIN"]}}}`, + }, + { + name: "create invalid quotas", + jobType: apiclient.JOBTYPE_CREATE_BOX_WITH_CAPABILITIES_V2, + payload: `{"id":"box-1","image":"alpine:latest","osUser":"boxlite","cpuQuota":0,"gpuQuota":0,"memoryQuota":0,"storageQuota":0,"advanced":{"capabilities":{"add":["SYS_ADMIN"]}}}`, + }, + { + name: "recover missing error reason", + jobType: apiclient.JOBTYPE_RECOVER_BOX_WITH_CAPABILITIES_V2, + payload: `{"osUser":"boxlite","cpuQuota":1,"gpuQuota":0,"memoryQuota":1,"storageQuota":1,"advanced":{"capabilities":{"drop":["NET_RAW"]}}}`, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + capture := &capabilityCaptureBackend{} + executor := &Executor{backend: capture} + job := apiclient.NewJob( + "job-1", + test.jobType, + apiclient.JOBSTATUS_PENDING, + "box", + "box-1", + "2026-01-01T00:00:00Z", + ) + job.Payload = &test.payload + + _, err := executor.executeJob(context.Background(), job) + if err == nil || !strings.Contains(err.Error(), "validate payload") { + t.Fatalf("expected payload validation error, got %v", err) + } + if capture.createRequest != nil || capture.recoverRequest != nil { + t.Fatal("invalid strict capability job reached backend") + } + }) + } +} + +func TestCapabilityJobsRejectUnknownNestedFieldsBeforeBackend(t *testing.T) { + tests := []struct { + name string + jobType apiclient.JobType + payload string + }{ + { + name: "create top-level", + jobType: apiclient.JOBTYPE_CREATE_BOX_WITH_CAPABILITIES_V2, + payload: `{"id":"box-1","image":"alpine:latest","osUser":"boxlite","advanced":{"capabilities":{"add":["SYS_ADMIN"]}},"futureSecurityOption":true}`, + }, + { + name: "create advanced", + jobType: apiclient.JOBTYPE_CREATE_BOX_WITH_CAPABILITIES_V2, + payload: `{"id":"box-1","image":"alpine:latest","osUser":"boxlite","advanced":{"capabilities":{"add":["SYS_ADMIN"]},"futureSecurityOption":true}}`, + }, + { + name: "recover capabilities", + jobType: apiclient.JOBTYPE_RECOVER_BOX_WITH_CAPABILITIES_V2, + payload: `{"osUser":"boxlite","errorReason":"retry","advanced":{"capabilities":{"drop":["NET_RAW"],"futureCapabilityOption":true}}}`, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + capture := &capabilityCaptureBackend{} + executor := &Executor{backend: capture} + job := apiclient.NewJob( + "job-1", + test.jobType, + apiclient.JOBSTATUS_PENDING, + "box", + "box-1", + "2026-01-01T00:00:00Z", + ) + job.Payload = &test.payload + + _, err := executor.executeJob(context.Background(), job) + if err == nil || !strings.Contains(err.Error(), "unknown field") { + t.Fatalf("expected recursive unknown-field rejection, got %v", err) + } + if capture.createRequest != nil || capture.recoverRequest != nil { + t.Fatal("invalid strict capability job reached backend") + } + }) + } +} + +func TestCapabilityJobsRejectNullNestedFieldsBeforeBackend(t *testing.T) { + tests := []struct { + name string + jobType apiclient.JobType + payload string + }{ + { + name: "create null advanced", + jobType: apiclient.JOBTYPE_CREATE_BOX_WITH_CAPABILITIES_V2, + payload: `{"id":"box-1","image":"alpine:latest","osUser":"boxlite","advanced":null}`, + }, + { + name: "recover null capabilities", + jobType: apiclient.JOBTYPE_RECOVER_BOX_WITH_CAPABILITIES_V2, + payload: `{"osUser":"boxlite","errorReason":"retry","advanced":{"capabilities":null}}`, + }, + { + name: "create null add", + jobType: apiclient.JOBTYPE_CREATE_BOX_WITH_CAPABILITIES_V2, + payload: `{"id":"box-1","image":"alpine:latest","osUser":"boxlite","advanced":{"capabilities":{"add":null,"drop":["NET_RAW"]}}}`, + }, + { + name: "recover null drop", + jobType: apiclient.JOBTYPE_RECOVER_BOX_WITH_CAPABILITIES_V2, + payload: `{"osUser":"boxlite","errorReason":"retry","advanced":{"capabilities":{"add":["SYS_ADMIN"],"drop":null}}}`, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + capture := &capabilityCaptureBackend{} + executor := &Executor{backend: capture} + job := apiclient.NewJob( + "job-1", + test.jobType, + apiclient.JOBSTATUS_PENDING, + "box", + "box-1", + "2026-01-01T00:00:00Z", + ) + job.Payload = &test.payload + + if _, err := executor.executeJob(context.Background(), job); err == nil { + t.Fatal("expected null-field rejection") + } + if capture.createRequest != nil || capture.recoverRequest != nil { + t.Fatal("null-bearing strict capability job reached backend") + } + }) + } +} diff --git a/apps/runner/pkg/runner/v2/executor/executor.go b/apps/runner/pkg/runner/v2/executor/executor.go index b59aaae55..418131e30 100644 --- a/apps/runner/pkg/runner/v2/executor/executor.go +++ b/apps/runner/pkg/runner/v2/executor/executor.go @@ -131,6 +131,8 @@ func (e *Executor) executeJob(ctx context.Context, job *apiclient.Job) (any, err switch job.GetType() { case apiclient.JOBTYPE_CREATE_BOX: resultMetadata, err = e.createBox(ctx, job) + case apiclient.JOBTYPE_CREATE_BOX_WITH_CAPABILITIES_V2: + resultMetadata, err = e.createBoxWithCapabilities(ctx, job) case apiclient.JOBTYPE_START_BOX: resultMetadata, err = e.startBox(ctx, job) case apiclient.JOBTYPE_STOP_BOX: @@ -143,6 +145,8 @@ func (e *Executor) executeJob(ctx context.Context, job *apiclient.Job) (any, err resultMetadata, err = e.updateNetworkSettings(ctx, job) case apiclient.JOBTYPE_RECOVER_BOX: resultMetadata, err = e.recoverBox(ctx, job) + case apiclient.JOBTYPE_RECOVER_BOX_WITH_CAPABILITIES_V2: + resultMetadata, err = e.recoverBoxWithCapabilities(ctx, job) default: err = fmt.Errorf("unknown job type: %s", job.GetType()) } diff --git a/apps/runner/pkg/runner/v2/healthcheck/healthcheck.go b/apps/runner/pkg/runner/v2/healthcheck/healthcheck.go index a5397cd79..e5c62a6fe 100644 --- a/apps/runner/pkg/runner/v2/healthcheck/healthcheck.go +++ b/apps/runner/pkg/runner/v2/healthcheck/healthcheck.go @@ -98,6 +98,7 @@ func (s *Service) sendHealthcheck(ctx context.Context) error { defer cancel() healthcheck := apiclient.NewRunnerHealthcheck(internal.Version) + healthcheck.SetFeatures([]string{internal.FeatureLinuxCapabilitiesV2}) healthcheck.SetDomain(s.domain) proxyUrl := fmt.Sprintf("http://%s:%d", s.domain, s.proxyPort) diff --git a/docs/architecture/README.md b/docs/architecture/README.md index dc4b4d1b2..3a13f4714 100644 --- a/docs/architecture/README.md +++ b/docs/architecture/README.md @@ -2,6 +2,8 @@ Related design: [AutoPause / AutoResume / AutoDelete](./auto-pause-resume-design.md) +Container security design: [Linux capability API](./container-capabilities.md) + ## Overview BoxLite is an embeddable virtual machine runtime that follows the SQLite philosophy: a library that diff --git a/docs/architecture/container-capabilities.md b/docs/architecture/container-capabilities.md new file mode 100644 index 000000000..d0b04b391 --- /dev/null +++ b/docs/architecture/container-capabilities.md @@ -0,0 +1,155 @@ +# Linux capability API design + +Status: accepted + +## Decision + +BoxLite exposes a high-level delta policy rather than the OCI runtime's five +exact capability sets. The policy is grouped with the other expert-only +container settings instead of widening the top-level box API: + +| Surface | Add | Drop | +| --- | --- | --- | +| Rust, Python, REST | `advanced.capabilities.add` | `advanced.capabilities.drop` | +| Node.js / TypeScript | `advanced.capabilities.add` | `advanced.capabilities.drop` | +| Go | `AdvancedBoxOptions.SetCapabilities({Add: ...})` | `AdvancedBoxOptions.SetCapabilities({Drop: ...})` | +| C | `boxlite_advanced_options_set_capabilities_add` | `boxlite_advanced_options_set_capabilities_drop` | +| CLI | repeatable `--cap-add` | repeatable `--cap-drop` | + +Create inputs and inspection outputs use that same nested path. Inspection uses +a dedicated read-only advanced-info type so it exposes the effective capability +policy without leaking runtime security or health-check configuration. The CLI +flags remain familiar Docker-style shorthands and populate the nested object. + +The values remain strings rather than a public enum. Linux can add +capabilities independently of an SDK release, and the API host may run a +different kernel and OCI library from the BoxLite guest. Boundaries validate +the string shape; the guest that owns the OCI runtime validates support. + +Names are case-insensitive, may omit `CAP_`, and are deduplicated as sets. +Empty lists preserve BoxLite's 14-capability baseline. Resolution follows +Moby and Apple container: + +1. With `add=["ALL"]`, start from every capability supported by the guest + and remove explicitly named drops. Named drops win in this branch. +2. Otherwise, with `drop=["ALL"]`, keep only explicitly named additions. +3. Otherwise, start from the baseline, apply drops, then apply additions. + A named addition therefore wins a named conflict. + +Adding capabilities weakens the container boundary; `SYS_ADMIN` and `ALL` are +especially broad. Prefer `drop=["ALL"]` plus only the minimum additions a +workload needs. BoxLite's VM boundary remains separate, but it is not a reason +to grant unnecessary privilege inside the guest. + +The resolved set populates OCI bounding, effective, and permitted sets for +init and every later exec. Inheritable and ambient remain absent. They have +different privilege propagation semantics and require a separate, explicit +security design if BoxLite ever exposes them. + +Internally, the guest resolves the two input lists once into a `CapabilitySet`. +That facade owns parsing, default policy, `ALL` precedence, canonical names for +libcontainer, and OCI set construction. Downstream init/exec code carries only +the resolved type and cannot reinterpret the policy. + +## Compatibility and rollout + +Capability policy is negotiated at every versioned boundary. A remote SDK +rechecks `linux_capabilities_enabled` from `GET /v1/config` immediately before +creating a box with a custom policy, then posts to the strict create route that +schema-unaware API builds do not expose. This closes both stale discovery-cache +and mixed-version load-balancer fail-open paths. A BoxLite host requires the guest's +`linux-capabilities-v2` feature before sending the nested policy. The cloud control +plane likewise schedules capability-bearing boxes only onto runners +advertising the feature, and the start/restart action checks uncached persisted +runner state again immediately before invoking the runner. Missing +advertisements therefore fail closed; the second runner check also narrows the +selection-to-dispatch race. + +Remote inspection uses the versioned strict get and list routes and rejects +every box response that omits `advanced.capabilities`. Legacy read routes stay +available for older clients, but capability-aware clients deliberately trade +old-server inspection compatibility for authoritative security metadata. + +The structured host/guest protobuf reserves the prototype's flat field names +and tags 6/7, then carries `advanced` on tag 8. The nested message and the old +repeated strings share a length-delimited wire type, so reusing either tag +could let a mixed-version guest interpret an encoded message as a capability +name. The `-v2` feature token and capability-specific v2 job kinds similarly +keep queued flat prototype payloads from being reinterpreted as the nested +contract. + +Persistence has explicit downgrade barriers. Opening a local database migrates +its schema to v9, so a v8 binary refuses to reopen it instead of discarding +persisted capability fields. Ordinary exports remain archive v3 for backward +compatibility; an export carrying `advanced.capabilities` is archive v4, which +older importers reject. Imported archive options are validated before any disk +is installed or box metadata is persisted. + +An older cloud API cannot understand fields that did not exist in its schema. +For a mixed-version deployment, roll out the database migration and control +plane first, then capable runners and guests, and expose capability-aware +clients only after that path is healthy. The new control plane safely rejects +custom policies while only old runners are available. Ordinary create, get, +and list operations from older clients remain compatible throughout the +rollout. Named `get_or_create` deliberately requires the strict policy-aware +read route even for an empty requested policy: otherwise an old API could omit a persisted +custom policy and make reuse appear safe. Drain a runner before downgrading or +rolling it back: a previously positive advertisement cannot prove that the +runner binary has not changed since its last heartbeat. Once a custom-policy +box has been accepted, do not roll the control plane back to a build that +predates these fields: such a build cannot preserve them while recreating or +recovering a box. Roll forward to a capability-aware build instead. + +## Project research + +The projects below were reviewed at their current primary-source interfaces. +Defaults differ by product, but direct container APIs consistently favor an +add/drop delta over exposing all five OCI sets. + +| Project | Interface and relevant behavior | +| --- | --- | +| Docker CLI | Repeatable string-list flags, forwarded without client-side semantic validation ([opts.go:150-156](https://github.com/docker/cli/blob/5b21d378b0db9eda911a169fd72cacb9f00da685/cli/command/container/opts.go#L150-L156), [opts.go:669-695](https://github.com/docker/cli/blob/5b21d378b0db9eda911a169fd72cacb9f00da685/cli/command/container/opts.go#L669-L695)). | +| Moby Engine | Flat `CapAdd` / `CapDrop` string arrays ([hostconfig.go:418-435](https://github.com/moby/moby/blob/2196ab2eec2aebaf92201056ea52475880397169/api/types/container/hostconfig.go#L418-L435)); 14-capability default and the precedence adopted above ([defaults.go:3-20](https://github.com/moby/moby/blob/2196ab2eec2aebaf92201056ea52475880397169/daemon/pkg/oci/caps/defaults.go#L3-L20), [utils.go:72-117](https://github.com/moby/moby/blob/2196ab2eec2aebaf92201056ea52475880397169/daemon/pkg/oci/caps/utils.go#L72-L117)). | +| Docker Compose | Flat `cap_add` / `cap_drop` sequences delegated to the engine ([Compose service specification](https://docs.docker.com/reference/compose-file/services/#cap_add)). | +| docker-py | Optional `cap_add` / `cap_drop` lists, not a closed capability enum ([containers.py:264-269](https://github.com/docker/docker-py/blob/main/docker/types/containers.py#L264-L269)). | +| Docker.DotNet | `IList` fields mirror Moby ([HostConfig.Generated.cs:80-87](https://github.com/dotnet/Docker.DotNet/blob/master/src/Docker.DotNet/Models/HostConfig.Generated.cs#L80-L87)). | +| Bollard | Rust `Option>` fields mirror Moby ([HostConfig.cap_add](https://docs.rs/bollard/latest/bollard/service/struct.HostConfig.html#structfield.cap_add)). | +| Podman/libpod | Flat string arrays ([specgen.go:394-401](https://github.com/containers/podman/blob/e36e1a41c69ea9f6096ed628a71920f315f34514/pkg/specgen/specgen.go#L394-L401)); its native endpoint rejects overlap, while its Docker-compatible endpoint accepts Moby's shape ([capabilities.go:125-196](https://github.com/containers/podman/blob/e36e1a41c69ea9f6096ed628a71920f315f34514/vendor/go.podman.io/common/pkg/capabilities/capabilities.go#L125-L196)). | +| podman-py | `list[str]` under the same keyword names ([containers_create.py:615-620](https://github.com/containers/podman-py/blob/main/podman/domain/containers_create.py#L615-L620)). | +| nerdctl | String slices and repeatable/comma-compatible flags; warns rather than freezing unknown names in the client ([container_run.go:218-222](https://github.com/containerd/nerdctl/blob/d79ad647152503c2740c90c1329cf985421e37b0/cmd/nerdctl/container/container_run.go#L218-L222), [run_security_linux.go:176-235](https://github.com/containerd/nerdctl/blob/d79ad647152503c2740c90c1329cf985421e37b0/pkg/cmd/container/run_security_linux.go#L176-L235)). | +| containerd | Lower-level ordered `SpecOpts`; add/drop mutate bounding, effective, and permitted, with inheritable/ambient handled separately ([spec_opts.go:1066-1143](https://github.com/containerd/containerd/blob/aad11006b869517fcd3009450b6f82da282e1a9b/pkg/oci/spec_opts.go#L1066-L1143)). | +| Kubernetes API | Nested `Capabilities { Add, Drop }`, but `Capability` is an open string newtype rather than an enum ([types.go:3040-3052](https://github.com/kubernetes/api/blob/master/core/v1/types.go#L3040-L3052)). | +| Kubernetes CRI | Repeated add/drop strings plus a separate ambient-add field; ordinary add and ambient add are intentionally distinct ([api.proto:1026-1041](https://github.com/kubernetes/cri-api/blob/791729b255f0c2d0019d3862ba6ef000c4a30c4d/pkg/apis/runtime/v1/api.proto#L1026-L1041)). | +| CRI-O | Guest/runtime-side validation, product-specific default, add/drop resolution, and deliberate clearing of ambient/inheritable sets ([capabilities_linux.go:11-42](https://github.com/cri-o/cri-o/blob/65f79695590b9f53e5f69b34382146cdac8ab5c0/internal/config/capabilities/capabilities_linux.go#L11-L42), [container.go:640-785](https://github.com/cri-o/cri-o/blob/65f79695590b9f53e5f69b34382146cdac8ab5c0/internal/factory/container/container.go#L640-L785)). | +| Nomad Docker driver | Flat task fields plus an operator allowlist; its default intentionally differs from Docker by dropping `NET_RAW` ([config.go:377-392](https://github.com/hashicorp/nomad/blob/37c73b2918bd5798e285623d436644f3e5d2cb1b/drivers/docker/config.go#L377-L392), [defaults.go:14-31](https://github.com/hashicorp/nomad/blob/37c73b2918bd5798e285623d436644f3e5d2cb1b/drivers/shared/capabilities/defaults.go#L14-L31)). | +| Buildah | Add/drop string arrays with documented drop-wins conflicts ([run.go:155-160](https://github.com/containers/buildah/blob/18bf8e35f1a08b95a9847e383d340bd9e96f5097/run.go#L155-L160), [buildah-run.1.md:26-48](https://github.com/containers/buildah/blob/18bf8e35f1a08b95a9847e383d340bd9e96f5097/docs/buildah-run.1.md#L26-L48)). | +| Apple container | Persisted `capAdd` / `capDrop` arrays default to empty for backward compatibility and document the same Moby precedence BoxLite adopts ([ContainerConfiguration.swift:20-60](https://github.com/apple/container/blob/d1d763530df3c6a326dbae7f0c0a59a335808045/Sources/ContainerResource/Container/ContainerConfiguration.swift#L20-L60), [how-to.md:470-510](https://github.com/apple/container/blob/d1d763530df3c6a326dbae7f0c0a59a335808045/docs/how-to.md#L470-L510)). | +| LXC | `lxc.cap.drop` and mutually exclusive `lxc.cap.keep` provide subtractive and replacement policies ([lxc.container.conf:1811-1850](https://github.com/lxc/lxc/blob/dc15af12c6a12d2946a5178001b3c377e2a9c694/doc/lxc.container.conf.sgml.in#L1811-L1850)). | +| Incus | Exposes LXC capability controls through restricted `raw.lxc`; privileged containers receive product-specific drops ([config_options.txt:2372-2378](https://github.com/lxc/incus/blob/29b2ba74073c5cd033865f7154e2c77ba5744824/doc/config_options.txt#L2372-L2378), [driver_lxc.go:787-798](https://github.com/lxc/incus/blob/29b2ba74073c5cd033865f7154e2c77ba5744824/internal/server/instance/drivers/driver_lxc.go#L787-L798)). | +| systemd-nspawn | Separate add, drop, and ambient settings; ambient is explicitly not implied by ordinary additions ([systemd.nspawn.xml:190-240](https://github.com/systemd/systemd/blob/ba3b1eff0ba51d400475f4596677b2d429cb1a47/man/systemd.nspawn.xml#L190-L240)). | +| OCI Runtime Spec | Exact `bounding`, `effective`, `inheritable`, `permitted`, and `ambient` arrays, with no delta/default policy ([config.md:286-299](https://github.com/opencontainers/runtime-spec/blob/6999a89a76a0329f440d5740497bedb9dd431297/config.md#L286-L299)). | +| runc | Applies the five exact OCI sets and resets ambient state explicitly ([capabilities.go:47-149](https://github.com/opencontainers/runc/blob/8d2f7df5cdcbd8d26b15457a9201f1c0ad426459/libcontainer/capabilities/capabilities.go#L47-L149)). Its inheritable-capability exec advisory is why BoxLite does not infer inheritable/ambient from `cap_add` ([GHSA-f3fp-gc8g-vw66](https://github.com/opencontainers/runc/security/advisories/GHSA-f3fp-gc8g-vw66)). | +| AWS ECS | Nested `KernelCapabilities` with add/drop arrays and Docker-derived semantics ([KernelCapabilities API](https://docs.aws.amazon.com/AmazonECS/latest/APIReference/API_KernelCapabilities.html)). | +| Azure Container Instances | Nested fluent `add` / `drop` string lists ([SecurityContextCapabilitiesDefinition](https://learn.microsoft.com/en-us/java/api/com.azure.resourcemanager.containerinstance.models.securitycontextcapabilitiesdefinition)). | +| Terraform Docker provider | Declarative nested block, but still only add/drop lists ([resource_docker_container.go:262-290](https://github.com/kreuzwerker/terraform-provider-docker/blob/master/internal/provider/resource_docker_container.go#L262-L290)). | + +Kata Containers and gVisor were also checked. Both consume OCI/containerd or +Docker/Kubernetes contracts rather than defining a competing high-level +capability API, which supports keeping BoxLite's public delta policy separate +from its OCI realization. + +## Alternatives rejected + +- **Public closed enum:** safer autocomplete today, but prevents a newer guest + from accepting a newer kernel capability until every SDK is released again. +- **Public five-set OCI object:** precise but too low-level for the common + container use case and easy to misuse. Ambient and inheritable deserve + separate review. +- **Flat top-level add/drop fields:** common in Docker-compatible engine APIs, + but BoxLite's top-level options also cover application lifecycle and resource + settings. Grouping the expert-only privilege policy under `advanced` keeps + creation and inspection extensible and matches Kubernetes, ECS, ACI, and + Terraform's structured security models. +- **Host semantic validation:** the host and guest may carry different OCI + libraries or kernels. Freezing the supported list in Rust, TypeScript, and + every SDK creates version skew; only lexical validation belongs upstream. diff --git a/docs/reference/README.md b/docs/reference/README.md index 7c563ec55..25cba1caa 100644 --- a/docs/reference/README.md +++ b/docs/reference/README.md @@ -182,6 +182,26 @@ Host-side secret substitution rules for outbound HTTP(S) requests. - The placeholder is also exposed as `BOXLITE_SECRET_` inside the guest. - C SDK users configure secrets with `boxlite_options_add_secret()`. +#### `advanced.capabilities` + +Linux capability overrides for the container's init and all later exec +processes. Both lists default to empty, preserving BoxLite's Docker-compatible +14-capability baseline. + +- Names are case-insensitive and accept either `NET_ADMIN` or `CAP_NET_ADMIN`. +- `ALL` is supported in either list. +- Without `ALL`, drops are applied to the baseline before explicit additions, + so the same specifically named capability in both lists remains enabled. +- With `add=["ALL"]`, explicit drops win, matching Docker. +- Use `drop=["ALL"]` with explicit additions to construct a minimal set. +- Malformed names are rejected at the API boundary. A well-formed name that the + bundled guest runtime does not support is rejected during container initialization. +- Remote clients, hosts, and cloud runners negotiate support before creating + or starting a box; a custom policy is rejected rather than ignored when any + upgraded boundary is missing. +- The resolved set is applied to OCI bounding, effective, and permitted sets. + Inheritable and ambient capabilities stay unset; they are not implied by `add`. + #### `cpus: int` Number of CPU cores allocated to the box. diff --git a/docs/reference/c/README.md b/docs/reference/c/README.md index fbba5dfd2..7152b7849 100644 --- a/docs/reference/c/README.md +++ b/docs/reference/c/README.md @@ -117,6 +117,22 @@ int main() { return 1; } boxlite_options_set_network_enabled(opts); + CAdvancedBoxOptions* advanced = NULL; + if (boxlite_advanced_options_new(&advanced, &error) != Ok) { + boxlite_options_free(opts); + return 1; + } + const char* cap_add[] = {"NET_ADMIN"}; + const char* cap_drop[] = {"NET_RAW"}; + if (boxlite_advanced_options_set_capabilities_add(advanced, cap_add, 1) != Ok || + boxlite_advanced_options_set_capabilities_drop(advanced, cap_drop, 1) != Ok) { + fprintf(stderr, "Invalid Linux capability list\n"); + boxlite_advanced_options_free(advanced); + boxlite_options_free(opts); + return 1; + } + boxlite_options_set_advanced(opts, advanced); + boxlite_advanced_options_free(advanced); if (boxlite_create_box(runtime, opts, &box, &error) != Ok) { fprintf(stderr, "Error %d: %s\n", error.code, error.message); @@ -562,6 +578,22 @@ if (boxlite_options_new("alpine:3.19", &opts, &error) != Ok) { boxlite_options_set_cpus(opts, 2); boxlite_options_set_memory(opts, 512); boxlite_options_set_network_enabled(opts); +CAdvancedBoxOptions* advanced = NULL; +if (boxlite_advanced_options_new(&advanced, &error) != Ok) { + boxlite_options_free(opts); + return 1; +} +const char* cap_add[] = {"NET_ADMIN"}; +const char* cap_drop[] = {"NET_RAW"}; +if (boxlite_advanced_options_set_capabilities_add(advanced, cap_add, 1) != Ok || + boxlite_advanced_options_set_capabilities_drop(advanced, cap_drop, 1) != Ok) { + fprintf(stderr, "Invalid Linux capability list\n"); + boxlite_advanced_options_free(advanced); + boxlite_options_free(opts); + return 1; +} +boxlite_options_set_advanced(opts, advanced); +boxlite_advanced_options_free(advanced); CBoxHandle* box = NULL; if (boxlite_create_box(runtime, opts, &box, &error) != Ok) { @@ -784,6 +816,13 @@ if (code == Ok) { ### Discovery & Introspection +The original `CBoxInfo` layout remains stable for existing binaries. New code +should call `boxlite_box_info_v2`, `boxlite_get_info_v2`, and +`boxlite_list_info_v2`; `CBoxInfoV2.base` contains the original fields and the +versioned structure adds `advanced.capabilities.add` and +`advanced.capabilities.drop` string arrays. Free versioned +results with `boxlite_free_box_info_v2` or `boxlite_free_box_info_list_v2`. + #### boxlite_list_info List all boxes. @@ -883,6 +922,8 @@ BoxliteErrorCode boxlite_box_metrics( - `CBoxliteExecResult` → `boxlite_result_free()` - `CBoxInfo` → `boxlite_free_box_info()` - `CBoxInfoList` → `boxlite_free_box_info_list()` + - `CBoxInfoV2` → `boxlite_free_box_info_v2()` + - `CBoxInfoListV2` → `boxlite_free_box_info_list_v2()` - `CImagePullResult` → `boxlite_free_image_pull_result()` - `CImageInfoList` → `boxlite_free_image_info_list()` @@ -1055,10 +1096,13 @@ if (code != Ok) { | `boxlite_box_id()` | Get box ID | | `boxlite_box_free()` | Free box handle | | `boxlite_box_info()` | Get box info | +| `boxlite_box_info_v2()` | Get box info including capability policy | | `boxlite_box_metrics()` | Get box metrics | | `boxlite_execute()` | Execute command | | `boxlite_list_info()` | List all boxes | | `boxlite_get_info()` | Get box info by ID | +| `boxlite_list_info_v2()` | List boxes including capability policy | +| `boxlite_get_info_v2()` | Get box info by ID including capability policy | | `boxlite_simple_new()` | Create simple box | | `boxlite_simple_run()` | Run command (simple) | | `boxlite_simple_free()` | Free simple box | diff --git a/docs/reference/cli/README.md b/docs/reference/cli/README.md index fd34d0cbf..0faed29e8 100644 --- a/docs/reference/cli/README.md +++ b/docs/reference/cli/README.md @@ -246,7 +246,7 @@ The box's lifetime is that command's lifetime. When it exits, the box stops and takes the command's exit code; `boxlite ps` shows it stopped and `boxlite inspect -f '{{.State.ExitCode}}'` gives the code. -**Options:** Uses [`ProcessFlags`](#processflags) + [`ResourceFlags`](#resourceflags) + [`PublishFlags`](#publishflags) + [`VolumeFlags`](#volumeflags) + [`ManagementFlags`](#managementflags), plus: +**Options:** Uses [`ProcessFlags`](#processflags) + [`CapabilityFlags`](#capabilityflags) + [`ResourceFlags`](#resourceflags) + [`PublishFlags`](#publishflags) + [`VolumeFlags`](#volumeflags) + [`ManagementFlags`](#managementflags), plus: | Flag | Short | Description | |------|-------|-------------| @@ -266,6 +266,7 @@ boxlite run -it --rm alpine:latest /bin/sh boxlite run -d --name web -p 8080:80 nginx:alpine boxlite run -v $(pwd):/work -w /work alpine:latest ls -la boxlite run --cpus 4 --memory 4096 python:slim python -c "print(2+2)" +boxlite run --cap-add SYS_ADMIN --cap-drop NET_RAW alpine:latest sh boxlite run --rootfs /path/to/rootfs /bin/sh ``` @@ -321,7 +322,7 @@ implicitly, because starting it runs that command. Start it deliberately with | `--env KEY=VALUE` | `-e` | Set environment variables (repeatable) | | `--workdir PATH` | `-w` | Working directory inside the box | -Also uses [`ResourceFlags`](#resourceflags) + [`PublishFlags`](#publishflags) + [`VolumeFlags`](#volumeflags) + [`ManagementFlags`](#managementflags). +Also uses [`CapabilityFlags`](#capabilityflags) + [`ResourceFlags`](#resourceflags) + [`PublishFlags`](#publishflags) + [`VolumeFlags`](#volumeflags) + [`ManagementFlags`](#managementflags). > Note: `create` accepts `--env` and `--workdir` directly rather than via `ProcessFlags` (no `-i`/`-t`/`-u` here, since no command is being executed). @@ -330,6 +331,7 @@ Also uses [`ResourceFlags`](#resourceflags) + [`PublishFlags`](#publishflags) + ```bash boxlite create --name mybox alpine:latest boxlite create -p 8080:80 -v /data:/app/data --name web nginx:alpine +boxlite create --cap-drop ALL --cap-add NET_BIND_SERVICE --name web nginx:alpine boxlite create --rootfs /path/to/rootfs --name local-rootfs ``` @@ -613,6 +615,21 @@ Used by `run` and `exec` (defined at `src/cli/src/cli.rs:208-281`). `--tty` implies `--interactive` when stdin is a TTY. `--tty` without a TTY-attached stdin is a hard error. +### `CapabilityFlags` + +Used by `run` and `create` to adjust the Linux capability set inherited by the +container's init and every later `exec` process. + +| Flag | Description | +|------|-------------| +| `--cap-add CAPABILITY` | Add a capability; repeatable | +| `--cap-drop CAPABILITY` | Drop a capability; repeatable | + +Names are case-insensitive and may include the `CAP_` prefix. `ALL` is +supported. With neither flag, BoxLite keeps its Docker-compatible 14-capability +baseline. `--cap-drop ALL --cap-add NET_BIND_SERVICE` creates a minimal set +containing only `NET_BIND_SERVICE`. + ### `ResourceFlags` Used by `run` and `create` (defined at `src/cli/src/cli.rs:287-310`). diff --git a/docs/reference/nodejs/README.md b/docs/reference/nodejs/README.md index 666577e46..9cfffab77 100644 --- a/docs/reference/nodejs/README.md +++ b/docs/reference/nodejs/README.md @@ -106,9 +106,24 @@ Configuration options for creating a box. | `network` | `NetworkSpec` | `{ mode: "enabled" }` | Structured network configuration | | `ports` | `JsPortSpec[]` | `[]` | Port mappings | | `secrets` | `Secret[]` | `[]` | Outbound HTTP(S) secret substitution rules | +| `advanced` | `AdvancedBoxOptions` | `{}` | Expert-only options, including `capabilities.add` and `capabilities.drop` | | `autoRemove` | `boolean` | `false` | Auto cleanup when stopped | | `detach` | `boolean` | `false` | Survive parent process exit | +Capability policy is intentionally nested with the other expert-only options: + +```typescript +const options = { + image: "alpine:latest", + advanced: { + capabilities: { + add: ["NET_BIND_SERVICE"], + drop: ["NET_RAW"], + }, + }, +}; +``` + #### `NetworkSpec` ```typescript @@ -199,6 +214,8 @@ Metadata about a box. | `createdAt` | `string` | Creation timestamp (ISO 8601) | | `lastUpdated` | `string` | Last state change (ISO 8601) | | `pid` | `number \| undefined` | Process ID (if running) | +| `advanced.capabilities.add` | `string[]` | Linux capabilities added to the default container set | +| `advanced.capabilities.drop` | `string[]` | Linux capabilities removed from the resulting container set | --- @@ -314,6 +331,12 @@ interface SimpleBoxOptions { network?: NetworkSpec; ports?: PortSpec[]; // Port mappings secrets?: Secret[]; + advanced?: { + capabilities?: { + add?: string[]; // Add Linux capabilities + drop?: string[]; // Remove Linux capabilities + }; + }; } ``` diff --git a/docs/reference/python/README.md b/docs/reference/python/README.md index f8ee50411..f927c1533 100644 --- a/docs/reference/python/README.md +++ b/docs/reference/python/README.md @@ -132,9 +132,26 @@ Configuration options for creating a box. | `network` | `NetworkSpec \| None` | `None` | Structured network configuration. Omit for default enabled networking. | | `ports` | `List[Tuple[int, int, str]]` | `[]` | Port forwarding as (host_port, guest_port, protocol) | | `secrets` | `List[Secret]` | `[]` | Outbound HTTP(S) secret substitution rules | +| `advanced` | `AdvancedBoxOptions \| None` | `None` | Expert-only options, including `capabilities.add` and `capabilities.drop` | | `auto_remove` | `bool` | `True` | Auto cleanup when stopped | | `detach` | `bool` | `False` | Survive parent process exit | +Capability policy is intentionally nested with the other expert-only options: + +```python +from boxlite import AdvancedBoxOptions, BoxOptions, ContainerCapabilities + +options = BoxOptions( + image="alpine:latest", + advanced=AdvancedBoxOptions( + capabilities=ContainerCapabilities( + add=["NET_BIND_SERVICE"], + drop=["NET_RAW"], + ) + ), +) +``` + #### `NetworkSpec` ```python @@ -227,6 +244,8 @@ Metadata about a box. | `image` | `str` | OCI image used | | `cpus` | `int` | Allocated CPU cores | | `memory_mib` | `int` | Allocated memory in MiB | +| `advanced.capabilities.add` | `List[str]` | Linux capabilities added to the default container set | +| `advanced.capabilities.drop` | `List[str]` | Linux capabilities removed from the resulting container set | --- diff --git a/docs/reference/rust/README.md b/docs/reference/rust/README.md index 68fc312dc..4097efcef 100644 --- a/docs/reference/rust/README.md +++ b/docs/reference/rust/README.md @@ -536,7 +536,7 @@ pub struct BoxOptions { /// Run independently of parent process (default: false) pub detach: bool, - /// Advanced options for expert users (security, mount isolation). Defaults are secure. + /// Advanced options for expert users (capabilities, security, mount isolation). pub advanced: AdvancedBoxOptions, } ``` @@ -544,6 +544,7 @@ pub struct BoxOptions { #### Example ```rust +use boxlite::{AdvancedBoxOptions, ContainerCapabilities}; use boxlite::runtime::options::{BoxOptions, RootfsSpec, VolumeSpec, PortSpec}; let options = BoxOptions { @@ -567,6 +568,13 @@ let options = BoxOptions { ..Default::default() }, ], + advanced: AdvancedBoxOptions { + capabilities: ContainerCapabilities { + add: vec!["SYS_ADMIN".to_string()], + drop: vec!["NET_RAW".to_string()], + }, + ..Default::default() + }, auto_remove: false, // Keep box after stop detach: true, // Run independently ..Default::default() @@ -579,6 +587,7 @@ Advanced options for expert users. Most users can ignore this — defaults prior ```rust pub struct AdvancedBoxOptions { + pub capabilities: ContainerCapabilities, pub security: SecurityOptions, pub isolate_mounts: bool, } @@ -586,6 +595,7 @@ pub struct AdvancedBoxOptions { | Field | Type | Default | Description | |-------|------|---------|-------------| +| `capabilities` | `ContainerCapabilities` | Empty add/drop lists | Linux capability delta policy for init and exec processes | | `security` | `SecurityOptions` | Compatibility defaults (jailer `true` on macOS; `false` on Linux/others) | Security isolation options (jailer, seccomp, namespaces) | | `isolate_mounts` | `bool` | `false` | Enable bind mount isolation (requires CAP_SYS_ADMIN on Linux) | diff --git a/make/test.mk b/make/test.mk index 910ef9824..0cc58d36c 100644 --- a/make/test.mk +++ b/make/test.mk @@ -1,4 +1,5 @@ PHONY_TARGETS += test +PHONY_TARGETS += test\:unit\:guest-capabilities # Mirrors GitHub Actions strategy.fail-fast. Default false: aggregator # targets run every sub-suite even if an earlier one fails, then exit @@ -206,6 +207,21 @@ test\:unit\:rust: fi; \ exit $$rc +# Guest capability policy is Linux-only and does not require a VM. Keep this +# focused target separate so macOS contributors can run the normal unit suite, +# while Linux CI executes the policy and OCI construction tests themselves. +test\:unit\:guest-capabilities: + @if [ "$$(uname)" != "Linux" ]; then \ + echo "⏭️ Guest capability unit tests require Linux"; \ + exit 0; \ + fi; \ + echo "🧪 Running guest capability unit tests..."; \ + if command -v cargo-nextest >/dev/null 2>&1; then \ + cargo nextest run --no-tests=fail -p boxlite-guest -E 'test(~capabilit)'; \ + else \ + cargo test -p boxlite-guest capabilit -- --test-threads=1; \ + fi + # Pre-warm Rust integration test image cache (internal helper, still callable). test\:warm-cache\:rust: $(if $(SETUP_DONE),,runtime\:debug) @echo "🔥 Warming Rust integration test image cache..." diff --git a/openapi/box.openapi.yaml b/openapi/box.openapi.yaml index ee6e8d5ec..ec17f6360 100644 --- a/openapi/box.openapi.yaml +++ b/openapi/box.openapi.yaml @@ -245,6 +245,71 @@ paths: schema: $ref: "#/components/schemas/ListBoxesResponse" + /{prefix}/boxes/strict: + parameters: + - $ref: "#/components/parameters/prefix" + + get: + operationId: listBoxesStrict + summary: List boxes with authoritative security metadata + description: | + Returns a paginated list through a route introduced with Linux + capability policy support. Clients use this route so an older server + fails instead of omitting `advanced.capabilities` and making a custom + policy appear to be the baseline. + tags: [Boxes] + parameters: + - $ref: "#/components/parameters/pageSize" + - $ref: "#/components/parameters/pageToken" + - name: status + in: query + description: Filter by box status + schema: + $ref: "#/components/schemas/BoxStatus" + responses: + "200": + description: List of boxes with authoritative capability policies + content: + application/json: + schema: + $ref: "#/components/schemas/ListBoxesResponse" + + post: + operationId: createBoxStrict + summary: Create a box with fail-closed option handling + description: | + Creates a box through a route introduced with strict option handling. + Clients MUST use this route when sending `advanced.capabilities` so a + schema-unaware server returns 404 instead of silently discarding those + security fields at the legacy create endpoint. + tags: [Boxes] + parameters: + - $ref: "#/components/parameters/idempotencyKey" + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/StrictCreateBoxRequest" + responses: + "201": + description: Box created + headers: + Location: + description: URL of the created box + schema: + type: string + content: + application/json: + schema: + $ref: "#/components/schemas/Box" + "400": + $ref: "#/components/responses/BadRequestError" + "409": + $ref: "#/components/responses/ConflictError" + "422": + $ref: "#/components/responses/UnprocessableEntityError" + /{prefix}/boxes/{box_id}: parameters: - $ref: "#/components/parameters/prefix" @@ -299,6 +364,30 @@ paths: "409": $ref: "#/components/responses/ConflictError" + /{prefix}/boxes/{box_id}/strict: + parameters: + - $ref: "#/components/parameters/prefix" + - $ref: "#/components/parameters/boxId" + + get: + operationId: getBoxStrict + summary: Get authoritative box details + description: | + Returns box metadata through a route introduced with Linux capability + policy support. Clients that must compare a persisted security policy + use this route so an older server fails with 404 instead of omitting + `advanced.capabilities`. + tags: [Boxes] + responses: + "200": + description: Box details with an authoritative capability policy + content: + application/json: + schema: + $ref: "#/components/schemas/Box" + "404": + $ref: "#/components/responses/NotFoundError" + # ------------------------------------------------------------------------- # Box Lifecycle # ------------------------------------------------------------------------- @@ -1249,6 +1338,10 @@ components: type: object description: Server capability limits and feature flags properties: + linux_capabilities_enabled: + type: boolean + description: Whether Docker-style Linux capability add/drop policy is supported + example: true max_cpus: type: integer description: Maximum allowed vCPUs per box @@ -1487,7 +1580,7 @@ components: Box: type: object description: Box metadata (maps to BoxInfo) - required: [box_id, status, created_at, updated_at, image, cpus, memory_mib] + required: [box_id, status, created_at, updated_at, image, cpus, memory_mib, advanced] properties: box_id: type: string @@ -1536,6 +1629,8 @@ components: minimum: 128 description: Allocated memory in MiB example: 512 + advanced: + $ref: "#/components/schemas/BoxAdvancedInfo" labels: type: object additionalProperties: @@ -1575,8 +1670,17 @@ components: - unknown CreateBoxRequest: + allOf: + - $ref: "#/components/schemas/CreateBoxRequestBase" + - type: object + description: Legacy create request accepted by `POST /boxes`. + properties: + security: + $ref: "#/components/schemas/SecurityPreset" + + CreateBoxRequestBase: type: object - description: Configuration for creating a new box (maps to BoxOptions) + description: Shared non-security configuration for creating a new box. properties: name: type: string @@ -1670,8 +1774,76 @@ components: type: boolean default: true description: Whether the box automatically resumes when accessed after AutoPause - security: - $ref: "#/components/schemas/SecurityPreset" + + StrictCreateBoxRequest: + allOf: + - $ref: "#/components/schemas/CreateBoxRequestBase" + - type: object + description: | + Fail-closed create request for security-sensitive options. This + schema is accepted only by `POST /boxes/strict`. + properties: + advanced: + $ref: "#/components/schemas/CreateBoxAdvancedOptions" + unevaluatedProperties: false + + CreateBoxAdvancedOptions: + type: object + description: Expert-only container options accepted by the strict create route. + additionalProperties: false + properties: + capabilities: + $ref: "#/components/schemas/CreateContainerCapabilities" + + CreateContainerCapabilities: + type: object + description: Linux capability additions and removals for container processes. + additionalProperties: false + properties: + add: + type: array + items: + type: string + default: [] + description: | + Linux capabilities to add to the default container capability set. + Names are case-insensitive, may include the `CAP_` prefix, and may be `ALL`. + example: [SYS_ADMIN] + drop: + type: array + items: + type: string + default: [] + description: | + Linux capabilities to remove from the container capability set. + Names are case-insensitive, may include the `CAP_` prefix, and may be `ALL`. + example: [NET_RAW] + + BoxAdvancedInfo: + type: object + description: Expert-only inspection metadata safe to expose to clients. + additionalProperties: false + required: [capabilities] + properties: + capabilities: + $ref: "#/components/schemas/ContainerCapabilities" + + ContainerCapabilities: + type: object + description: Authoritative Linux capability policy for container processes. + additionalProperties: false + required: [add, drop] + properties: + add: + type: array + items: + type: string + description: Linux capabilities added to the default container set. + drop: + type: array + items: + type: string + description: Linux capabilities removed from the resulting container set. VolumeSpec: type: object diff --git a/openapi/reference-server/server.py b/openapi/reference-server/server.py index 90ff5ebbe..d054de0c5 100644 --- a/openapi/reference-server/server.py +++ b/openapi/reference-server/server.py @@ -58,7 +58,7 @@ ) from fastapi.responses import JSONResponse, StreamingResponse from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer -from pydantic import BaseModel, ConfigDict, Field, model_validator +from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator from sse_starlette.sse import EventSourceResponse, ServerSentEvent import boxlite @@ -121,7 +121,40 @@ def validate_allow_net(self) -> "NetworkSpec": return self -class CreateBoxRequest(BaseModel): +class ContainerCapabilities(BaseModel): + model_config = ConfigDict(extra="forbid") + + add: list[str] = Field(default_factory=list) + drop: list[str] = Field(default_factory=list) + + @field_validator("add", "drop") + @classmethod + def validate_capabilities(cls, capabilities: list[str]) -> list[str]: + for capability in capabilities: + if not capability.isascii(): + raise ValueError("capability names must contain only ASCII characters") + normalized = capability.upper() + if normalized == "ALL": + continue + name = normalized.removeprefix("CAP_") + if ( + not name + or not name[0].isalpha() + or not all( + char.isalpha() or char.isdigit() or char == "_" for char in name + ) + ): + raise ValueError(f"malformed Linux capability: {capability}") + return capabilities + + +class CreateBoxAdvancedOptions(BaseModel): + model_config = ConfigDict(extra="forbid") + + capabilities: ContainerCapabilities = Field(default_factory=ContainerCapabilities) + + +class CreateBoxRequestBase(BaseModel): model_config = ConfigDict(extra="forbid") name: Optional[str] = None @@ -143,9 +176,25 @@ class CreateBoxRequest(BaseModel): auto_delete: Optional[int] = Field(default=None, ge=0) auto_resume: Optional[bool] = None detach: Optional[bool] = False + + +class CreateBoxRequest(CreateBoxRequestBase): + # Legacy reference-server compatibility only. The strict capability route + # intentionally excludes client-controlled sandbox policy. security: Optional[str] = None +class StrictCreateBoxRequest(CreateBoxRequestBase): + advanced: Optional[CreateBoxAdvancedOptions] = None + + @field_validator("advanced", mode="before") + @classmethod + def reject_null_advanced(cls, advanced): + if advanced is None: + raise ValueError("advanced must be an object when provided") + return advanced + + class StopBoxRequest(BaseModel): timeout_seconds: Optional[float] = 30 @@ -216,6 +265,7 @@ def get_server_config() -> ServerConfig: raise RuntimeError("server configuration not initialized") return state.server_config + # ============================================================================ # Error Mapping # ============================================================================ @@ -329,6 +379,8 @@ async def require_auth( def box_info_to_dict(info) -> dict: + advanced = getattr(info, "advanced", None) + capabilities = getattr(advanced, "capabilities", None) return { "box_id": info.id, "name": info.name, @@ -339,11 +391,17 @@ def box_info_to_dict(info) -> dict: "image": info.image, "cpus": info.cpus, "memory_mib": info.memory_mib, + "advanced": { + "capabilities": { + "add": list(getattr(capabilities, "add", [])), + "drop": list(getattr(capabilities, "drop", [])), + } + }, "labels": {}, } -def build_box_options(req: CreateBoxRequest) -> boxlite.BoxOptions: +def build_box_options(req: CreateBoxRequestBase) -> boxlite.BoxOptions: kwargs = {} if req.image and not req.rootfs_path: kwargs["image"] = req.image @@ -370,6 +428,16 @@ def build_box_options(req: CreateBoxRequest) -> boxlite.BoxOptions: kwargs["cmd"] = req.cmd if req.user is not None: kwargs["user"] = req.user + advanced = getattr(req, "advanced", None) + if advanced is not None and ( + advanced.capabilities.add or advanced.capabilities.drop + ): + kwargs["advanced"] = boxlite.AdvancedBoxOptions( + capabilities=boxlite.ContainerCapabilities( + add=advanced.capabilities.add, + drop=advanced.capabilities.drop, + ) + ) if req.secrets: kwargs["secrets"] = [ boxlite.Secret( @@ -398,14 +466,15 @@ def build_box_options(req: CreateBoxRequest) -> boxlite.BoxOptions: (p.get("host_port", 0), p["guest_port"], p.get("protocol", "tcp")) for p in req.ports ] - if req.security: + security = getattr(req, "security", None) + if security: presets = { "development": boxlite.SecurityOptions.development, "standard": boxlite.SecurityOptions.standard, "maximum": boxlite.SecurityOptions.maximum, } - if req.security in presets: - kwargs["security"] = presets[req.security]() + if security in presets: + kwargs["security"] = presets[security]() return boxlite.BoxOptions(**kwargs) @@ -543,6 +612,7 @@ async def get_config(): }, "overrides": {}, "capabilities": { + "linux_capabilities_enabled": True, "max_cpus": 32, "max_memory_mib": 16384, "max_disk_size_gb": 100, @@ -579,6 +649,19 @@ async def create_box( req: CreateBoxRequest, _auth: dict = Depends(require_auth), ): + return await create_box_with_options(prefix, req) + + +@app.post("/v1/{prefix}/boxes/strict", status_code=201) +async def create_box_strict( + prefix: str, + req: StrictCreateBoxRequest, + _auth: dict = Depends(require_auth), +): + return await create_box_with_options(prefix, req) + + +async def create_box_with_options(prefix: str, req: CreateBoxRequestBase): options = build_box_options(req) box_handle = await state.runtime.create(options, req.name) await cache_box_handle(box_handle) @@ -591,6 +674,7 @@ async def create_box( ) +@app.get("/v1/{prefix}/boxes/strict") @app.get("/v1/{prefix}/boxes") async def list_boxes( prefix: str, @@ -606,6 +690,7 @@ async def list_boxes( return {"boxes": boxes, "next_page_token": None} +@app.get("/v1/{prefix}/boxes/{box_id}/strict") @app.get("/v1/{prefix}/boxes/{box_id}") async def get_box( prefix: str, diff --git a/openapi/reference-server/tests/test_handle_cache.py b/openapi/reference-server/tests/test_handle_cache.py index 7356d5699..b1b6a21b9 100644 --- a/openapi/reference-server/tests/test_handle_cache.py +++ b/openapi/reference-server/tests/test_handle_cache.py @@ -43,6 +43,8 @@ def maximum(): module.Boxlite = _Noop module.Options = _Noop module.BoxOptions = _Noop + module.AdvancedBoxOptions = _Noop + module.ContainerCapabilities = _Noop module.CloneOptions = _Noop module.ExportOptions = _Noop module.SnapshotOptions = _Noop @@ -155,6 +157,64 @@ async def test_create_box_caches_handle(self) -> None: self.assertEqual(payload["box_id"], "box-create") self.assertIn("box-create", SERVER.state.active_boxes_by_id) + def test_build_box_options_forwards_capability_policy(self) -> None: + request = SERVER.StrictCreateBoxRequest( + advanced=SERVER.CreateBoxAdvancedOptions( + capabilities=SERVER.ContainerCapabilities( + add=["SYS_ADMIN"], + drop=["CAP_NET_RAW"], + ) + ), + ) + + capabilities = object() + advanced = object() + with ( + patch.object( + SERVER.boxlite, + "ContainerCapabilities", + return_value=capabilities, + ) as capabilities_constructor, + patch.object( + SERVER.boxlite, + "AdvancedBoxOptions", + return_value=advanced, + ) as advanced_constructor, + patch.object( + SERVER.boxlite, + "BoxOptions", + return_value=object(), + ) as constructor, + ): + SERVER.build_box_options(request) + + capabilities_constructor.assert_called_once_with( + add=["SYS_ADMIN"], + drop=["CAP_NET_RAW"], + ) + advanced_constructor.assert_called_once_with(capabilities=capabilities) + constructor.assert_called_once_with( + image="alpine:latest", + advanced=advanced, + detach=False, + ) + + def test_create_box_rejects_malformed_capability_policy(self) -> None: + for capability in ("NET-ADMIN", "123", "ß"): + with self.assertRaises(ValueError): + SERVER.StrictCreateBoxRequest( + advanced=SERVER.CreateBoxAdvancedOptions( + capabilities=SERVER.ContainerCapabilities(add=[capability]) + ) + ) + + def test_strict_create_does_not_expose_client_security_policy(self) -> None: + with self.assertRaises(ValueError): + SERVER.StrictCreateBoxRequest(security="development") + + schema = SERVER.StrictCreateBoxRequest.model_json_schema() + self.assertNotIn("security", schema["properties"]) + async def test_clone_box_caches_cloned_handle(self) -> None: source = _make_box_handle("box-source") cloned = _make_box_handle("box-cloned") diff --git a/sdks/c/README.md b/sdks/c/README.md index 2c8c77eb7..c63e3b818 100644 --- a/sdks/c/README.md +++ b/sdks/c/README.md @@ -218,6 +218,24 @@ int main() { } boxlite_options_set_network_enabled(opts); + CAdvancedBoxOptions* advanced = NULL; + if (boxlite_advanced_options_new(&advanced, &error) != Ok) { + boxlite_options_free(opts); + boxlite_runtime_free(runtime); + return 1; + } + const char* cap_add[] = {"NET_ADMIN"}; + const char* cap_drop[] = {"NET_RAW"}; + if (boxlite_advanced_options_set_capabilities_add(advanced, cap_add, 1) != Ok || + boxlite_advanced_options_set_capabilities_drop(advanced, cap_drop, 1) != Ok) { + fprintf(stderr, "Invalid Linux capability list\n"); + boxlite_advanced_options_free(advanced); + boxlite_options_free(opts); + return 1; + } + boxlite_options_set_advanced(opts, advanced); + boxlite_advanced_options_free(advanced); + if (boxlite_create_box(runtime, opts, &box, &error) != Ok) { fprintf(stderr, "Error %d: %s\n", error.code, error.message); boxlite_error_free(&error); @@ -475,6 +493,12 @@ if (boxlite_execute(box, &cmd, my_callback, NULL, &execution, &error) == Ok) { #### Discovery & Introspection +New code should use the ABI-safe `boxlite_box_info_v2`, +`boxlite_get_info_v2`, and `boxlite_list_info_v2` variants. `CBoxInfoV2` +embeds the stable v1 fields as `base`. Its capability policy is available at +`info->advanced.capabilities.add` and `.drop`; release the recursively owned +arrays with the matching `_v2` free function. + ```c // List all boxes BoxliteErrorCode boxlite_list_info( @@ -662,6 +686,8 @@ make - `CBoxliteExecResult` → `boxlite_result_free()` - `CBoxInfo` → `boxlite_free_box_info()` - `CBoxInfoList` → `boxlite_free_box_info_list()` + - `CBoxInfoV2` → `boxlite_free_box_info_v2()` + - `CBoxInfoListV2` → `boxlite_free_box_info_list_v2()` - `CImagePullResult` → `boxlite_free_image_pull_result()` - `CImageInfoList` → `boxlite_free_image_info_list()` diff --git a/sdks/c/include/boxlite.h b/sdks/c/include/boxlite.h index afc4d36c8..09e502202 100644 --- a/sdks/c/include/boxlite.h +++ b/sdks/c/include/boxlite.h @@ -290,6 +290,35 @@ typedef struct CBoxInfoList { // Box info list completion. typedef void (*CBoxInfoListCb)(struct CBoxInfoList*, CBoxliteError*, void*); +// Versioned box metadata that adds capability policy without changing the +// layout or array stride of the stable CBoxInfo ABI. +typedef struct CContainerCapabilities { + char **add; + int add_count; + char **drop; + int drop_count; +} CContainerCapabilities; + +typedef struct CBoxAdvancedInfo { + struct CContainerCapabilities capabilities; +} CBoxAdvancedInfo; + +typedef struct CBoxInfoV2 { + struct CBoxInfo base; + struct CBoxAdvancedInfo advanced; +} CBoxInfoV2; + +// Versioned box info completion with capability policy. +typedef void (*CBoxInfoV2Cb)(struct CBoxInfoV2*, CBoxliteError*, void*); + +typedef struct CBoxInfoListV2 { + struct CBoxInfoV2 *items; + int count; +} CBoxInfoListV2; + +// Versioned box info list completion with capability policy. +typedef void (*CBoxInfoListV2Cb)(struct CBoxInfoListV2*, CBoxliteError*, void*); + typedef struct CBoxMetrics { double cpu_percent; int64_t memory_bytes; @@ -410,6 +439,22 @@ void boxlite_advanced_options_free(CAdvancedBoxOptions *opts); // genuinely can't sandbox). Null `opts` is a no-op. void boxlite_advanced_options_set_security_enabled(CAdvancedBoxOptions *opts, int enabled); +// Replace the capabilities added to BoxLite's Docker-compatible baseline. +// +// A zero count clears the list. Negative counts, null handles, null arrays +// with a positive count, null elements, and invalid UTF-8 fail closed. +enum BoxliteErrorCode boxlite_advanced_options_set_capabilities_add(CAdvancedBoxOptions *opts, + const char *const *capabilities, + int count); + +// Replace the capabilities removed from the container capability set. +// +// A zero count clears the list. Negative counts, null handles, null arrays +// with a positive count, null elements, and invalid UTF-8 fail closed. +enum BoxliteErrorCode boxlite_advanced_options_set_capabilities_drop(CAdvancedBoxOptions *opts, + const char *const *capabilities, + int count); + enum BoxliteErrorCode boxlite_create_box(CBoxliteRuntime *runtime, CBoxliteOptions *opts, CBoxCreateBoxCb cb, @@ -585,10 +630,29 @@ enum BoxliteErrorCode boxlite_list_info(CBoxliteRuntime *runtime, void *user_data, CBoxliteError *out_error); +enum BoxliteErrorCode boxlite_box_info_v2(CBoxHandle *handle, + struct CBoxInfoV2 **out_info, + CBoxliteError *out_error); + +enum BoxliteErrorCode boxlite_get_info_v2(CBoxliteRuntime *runtime, + const char *id_or_name, + CBoxInfoV2Cb cb, + void *user_data, + CBoxliteError *out_error); + +enum BoxliteErrorCode boxlite_list_info_v2(CBoxliteRuntime *runtime, + CBoxInfoListV2Cb cb, + void *user_data, + CBoxliteError *out_error); + void boxlite_free_box_info(struct CBoxInfo *info); void boxlite_free_box_info_list(struct CBoxInfoList *list); +void boxlite_free_box_info_v2(struct CBoxInfoV2 *info); + +void boxlite_free_box_info_list_v2(struct CBoxInfoListV2 *list); + enum BoxliteErrorCode boxlite_box_metrics(CBoxHandle *handle, CBoxMetricsCb cb, void *user_data, @@ -715,7 +779,7 @@ void boxlite_options_set_auto_resume_enabled(CBoxliteOptions *opts, int val); void boxlite_options_set_detach(CBoxliteOptions *opts, int val); -// Apply a `CAdvancedBoxOptions` (security, mount isolation, health check) to a +// Apply a `CAdvancedBoxOptions` (capabilities, security, mount isolation, health check) to a // `CBoxliteOptions`. Clones the advanced configuration into the box options — // the caller retains ownership of `advanced_opts` and is responsible for // freeing it via `boxlite_advanced_options_free`. diff --git a/sdks/c/src/advanced_options.rs b/sdks/c/src/advanced_options.rs index 29fb9a153..3e93686da 100644 --- a/sdks/c/src/advanced_options.rs +++ b/sdks/c/src/advanced_options.rs @@ -1,17 +1,18 @@ //! C ABI for `boxlite::runtime::advanced_options::AdvancedBoxOptions`. //! -//! Mirrors the core model: advanced knobs (security, mount isolation, health -//! check) live under `BoxOptions.advanced`, never directly on the box. Build a +//! Mirrors the core model: advanced knobs (capabilities, security, mount +//! isolation, health check) live under `BoxOptions.advanced`, never directly on the box. Build a //! `CAdvancedBoxOptions` handle via `boxlite_advanced_options_new`, toggle the //! sandbox with `boxlite_advanced_options_set_security_enabled`, then apply it //! to a `CBoxliteOptions` via `boxlite_options_set_advanced`. -use std::os::raw::c_int; +use std::os::raw::{c_char, c_int}; use boxlite::runtime::advanced_options::{AdvancedBoxOptions, SecurityOptions}; use crate::CAdvancedBoxOptions; use crate::error::{BoxliteErrorCode, FFIError, null_pointer_error, write_error}; +use crate::util::c_str_to_string; /// Opaque handle wrapping an `AdvancedBoxOptions`. Allocated via /// `boxlite_advanced_options_new`, freed via `boxlite_advanced_options_free`. @@ -77,3 +78,90 @@ pub unsafe extern "C" fn boxlite_advanced_options_set_security_enabled( }; } } + +/// Replace the capabilities added to BoxLite's Docker-compatible baseline. +/// +/// A zero count clears the list. Negative counts, null handles, null arrays +/// with a positive count, null elements, and invalid UTF-8 fail closed. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn boxlite_advanced_options_set_capabilities_add( + opts: *mut CAdvancedBoxOptions, + capabilities: *const *const c_char, + count: c_int, +) -> BoxliteErrorCode { + set_capability_list(opts, capabilities, count, |options, values| { + options.capabilities.add = values; + }) +} + +/// Replace the capabilities removed from the container capability set. +/// +/// A zero count clears the list. Negative counts, null handles, null arrays +/// with a positive count, null elements, and invalid UTF-8 fail closed. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn boxlite_advanced_options_set_capabilities_drop( + opts: *mut CAdvancedBoxOptions, + capabilities: *const *const c_char, + count: c_int, +) -> BoxliteErrorCode { + set_capability_list(opts, capabilities, count, |options, values| { + options.capabilities.drop = values; + }) +} + +const INVALID_CAPABILITY_INPUT: &str = ""; + +fn set_capability_list( + handle: *mut CAdvancedBoxOptions, + capabilities: *const *const c_char, + count: c_int, + assign: impl FnOnce(&mut AdvancedBoxOptions, Vec), +) -> BoxliteErrorCode { + let Some(handle) = (unsafe { handle.as_mut() }) else { + return BoxliteErrorCode::InvalidArgument; + }; + + match parse_capability_array(capabilities, count) { + Ok(values) => { + assign(&mut handle.options, values); + BoxliteErrorCode::Ok + } + Err(()) => { + // Keep the handle invalid if a caller ignores the return code. The + // subsequent BoxOptions::sanitize call then rejects the policy + // instead of silently falling back to the baseline. + assign( + &mut handle.options, + vec![INVALID_CAPABILITY_INPUT.to_string()], + ); + BoxliteErrorCode::InvalidArgument + } + } +} + +fn parse_capability_array( + capabilities: *const *const c_char, + count: c_int, +) -> Result, ()> { + if count < 0 { + return Err(()); + } + if count == 0 { + return Ok(Vec::new()); + } + if capabilities.is_null() { + return Err(()); + } + + let mut values = Vec::with_capacity(count as usize); + unsafe { + for index in 0..count { + let capability = *capabilities.add(index as usize); + if capability.is_null() { + return Err(()); + } + values.push(c_str_to_string(capability).map_err(|_| ())?); + } + } + Ok(values) +} diff --git a/sdks/c/src/event_queue.rs b/sdks/c/src/event_queue.rs index ceaa0fda8..c3742daed 100644 --- a/sdks/c/src/event_queue.rs +++ b/sdks/c/src/event_queue.rs @@ -12,7 +12,7 @@ use std::sync::{Condvar, Mutex}; use boxlite::BoxliteError; use crate::images::{CImageInfoList, CImagePullResult}; -use crate::info::{CBoxInfo, CBoxInfoList}; +use crate::info::{CBoxInfo, CBoxInfoList, CBoxInfoListV2, CBoxInfoV2}; use crate::metrics::{CBoxMetrics, CRuntimeMetrics}; use crate::volumes::{CVolumeInfo, CVolumeInfoList}; @@ -161,6 +161,18 @@ pub type CBoxInfoListCb = pub(crate) type CBoxInfoListFn = extern "C" fn(*mut CBoxInfoList, *mut crate::CBoxliteError, *mut c_void); +/// Versioned box info completion with capability policy. +pub type CBoxInfoV2Cb = + Option; +pub(crate) type CBoxInfoV2Fn = + extern "C" fn(*mut CBoxInfoV2, *mut crate::CBoxliteError, *mut c_void); + +/// Versioned box info list completion with capability policy. +pub type CBoxInfoListV2Cb = + Option; +pub(crate) type CBoxInfoListV2Fn = + extern "C" fn(*mut CBoxInfoListV2, *mut crate::CBoxliteError, *mut c_void); + /// Per-box metrics completion. pub type CBoxMetricsCb = Option; @@ -383,6 +395,16 @@ pub enum RuntimeEvent { user_data: usize, result: Result, BoxliteError>, }, + InfoV2 { + cb: CBoxInfoV2Fn, + user_data: usize, + result: Result, BoxliteError>, + }, + InfoListV2 { + cb: CBoxInfoListV2Fn, + user_data: usize, + result: Result, BoxliteError>, + }, Metrics { cb: CBoxMetricsFn, user_data: usize, diff --git a/sdks/c/src/info.rs b/sdks/c/src/info.rs index d40bcde61..95f05ce8c 100644 --- a/sdks/c/src/info.rs +++ b/sdks/c/src/info.rs @@ -12,7 +12,9 @@ use boxlite::runtime::types::BoxStatus; use crate::box_handle::BoxHandle; use crate::error::{BoxliteErrorCode, FFIError, null_pointer_error, write_error}; -use crate::event_queue::{CBoxInfoCb, CBoxInfoListCb, RuntimeEvent, push_event}; +use crate::event_queue::{ + CBoxInfoCb, CBoxInfoListCb, CBoxInfoListV2Cb, CBoxInfoV2Cb, RuntimeEvent, push_event, +}; use crate::runtime::RuntimeHandle; use crate::{CBoxHandle, CBoxliteError, CBoxliteRuntime}; @@ -38,6 +40,33 @@ pub struct CBoxInfoList { pub count: c_int, } +/// Versioned box metadata that adds capability policy without changing the +/// layout or array stride of the stable CBoxInfo ABI. +#[repr(C)] +pub struct CContainerCapabilities { + pub add: *mut *mut c_char, + pub add_count: c_int, + pub drop: *mut *mut c_char, + pub drop_count: c_int, +} + +#[repr(C)] +pub struct CBoxAdvancedInfo { + pub capabilities: CContainerCapabilities, +} + +#[repr(C)] +pub struct CBoxInfoV2 { + pub base: CBoxInfo, + pub advanced: CBoxAdvancedInfo, +} + +#[repr(C)] +pub struct CBoxInfoListV2 { + pub items: *mut CBoxInfoV2, + pub count: c_int, +} + fn to_c_str(s: &str) -> *mut c_char { CString::new(s) .map(|c| c.into_raw()) @@ -79,6 +108,53 @@ impl CBoxInfo { } } +impl CBoxInfoV2 { + pub fn from_box_info(info: &boxlite::runtime::types::BoxInfo) -> Self { + Self { + base: CBoxInfo::from_box_info(info), + advanced: CBoxAdvancedInfo::from_box_info(&info.advanced), + } + } +} + +impl CBoxAdvancedInfo { + fn from_box_info(info: &boxlite::runtime::types::BoxAdvancedInfo) -> Self { + Self { + capabilities: CContainerCapabilities::from_capabilities(&info.capabilities), + } + } +} + +impl CContainerCapabilities { + fn from_capabilities( + capabilities: &boxlite::runtime::advanced_options::ContainerCapabilities, + ) -> Self { + let (add, add_count) = to_c_str_list(&capabilities.add); + let (drop, drop_count) = to_c_str_list(&capabilities.drop); + Self { + add, + add_count, + drop, + drop_count, + } + } +} + +fn to_c_str_list(values: &[String]) -> (*mut *mut c_char, c_int) { + if values.is_empty() { + return (ptr::null_mut(), 0); + } + let mut strings: Box<[*mut c_char]> = values + .iter() + .map(|value| to_c_str(value)) + .collect::>() + .into_boxed_slice(); + let count = strings.len() as c_int; + let items = strings.as_mut_ptr(); + Box::leak(strings); + (items, count) +} + pub unsafe fn free_box_info(info: *mut CBoxInfo) { unsafe { if info.is_null() { @@ -112,16 +188,83 @@ pub unsafe fn free_box_info_list(list: *mut CBoxInfoList) { free_box_info(list_ref.items.add(idx as usize)); } if !list_ref.items.is_null() { - drop(Vec::from_raw_parts( + drop(Box::from_raw(ptr::slice_from_raw_parts_mut( list_ref.items, list_ref.count as usize, - list_ref.count as usize, - )); + ))); } drop(Box::from_raw(list)); } } +pub unsafe fn free_box_info_v2(info: *mut CBoxInfoV2) { + unsafe { + if info.is_null() { + return; + } + let info = &mut *info; + free_box_info(&mut info.base); + free_box_advanced_info(&mut info.advanced); + } +} + +unsafe fn free_box_advanced_info(info: &mut CBoxAdvancedInfo) { + unsafe { + free_container_capabilities(&mut info.capabilities); + } +} + +unsafe fn free_container_capabilities(capabilities: &mut CContainerCapabilities) { + unsafe { + free_str_list(capabilities.add, capabilities.add_count); + free_str_list(capabilities.drop, capabilities.drop_count); + } +} + +pub unsafe fn free_box_info_v2_ptr(info: *mut CBoxInfoV2) { + unsafe { + if info.is_null() { + return; + } + free_box_info_v2(info); + drop(Box::from_raw(info)); + } +} + +pub unsafe fn free_box_info_list_v2(list: *mut CBoxInfoListV2) { + unsafe { + if list.is_null() { + return; + } + let list = &mut *list; + for index in 0..list.count { + free_box_info_v2(list.items.add(index as usize)); + } + if !list.items.is_null() { + drop(Box::from_raw(ptr::slice_from_raw_parts_mut( + list.items, + list.count as usize, + ))); + } + drop(Box::from_raw(list)); + } +} + +unsafe fn free_str_list(values: *mut *mut c_char, count: c_int) { + unsafe { + if values.is_null() { + return; + } + for index in 0..count { + free_str(*values.add(index as usize)); + } + drop(Box::from_raw(ptr::slice_from_raw_parts_mut( + values, + count as usize, + ))); + } +} + unsafe fn free_str(s: *mut c_char) { if !s.is_null() { #[cfg(test)] @@ -162,6 +305,36 @@ pub unsafe extern "C" fn boxlite_list_info( box_list(runtime, cb, user_data, out_error) } +#[unsafe(no_mangle)] +pub unsafe extern "C" fn boxlite_box_info_v2( + handle: *mut CBoxHandle, + out_info: *mut *mut CBoxInfoV2, + out_error: *mut CBoxliteError, +) -> BoxliteErrorCode { + box_info_v2(handle, out_info, out_error) +} + +#[unsafe(no_mangle)] +pub unsafe extern "C" fn boxlite_get_info_v2( + runtime: *mut CBoxliteRuntime, + id_or_name: *const c_char, + cb: CBoxInfoV2Cb, + user_data: *mut c_void, + out_error: *mut CBoxliteError, +) -> BoxliteErrorCode { + box_info_by_id_v2(runtime, id_or_name, cb, user_data, out_error) +} + +#[unsafe(no_mangle)] +pub unsafe extern "C" fn boxlite_list_info_v2( + runtime: *mut CBoxliteRuntime, + cb: CBoxInfoListV2Cb, + user_data: *mut c_void, + out_error: *mut CBoxliteError, +) -> BoxliteErrorCode { + box_list_v2(runtime, cb, user_data, out_error) +} + #[unsafe(no_mangle)] pub unsafe extern "C" fn boxlite_free_box_info(info: *mut CBoxInfo) { free_box_info_ptr(info) @@ -172,6 +345,16 @@ pub unsafe extern "C" fn boxlite_free_box_info_list(list: *mut CBoxInfoList) { free_box_info_list(list) } +#[unsafe(no_mangle)] +pub unsafe extern "C" fn boxlite_free_box_info_v2(info: *mut CBoxInfoV2) { + free_box_info_v2_ptr(info) +} + +#[unsafe(no_mangle)] +pub unsafe extern "C" fn boxlite_free_box_info_list_v2(list: *mut CBoxInfoListV2) { + free_box_info_list_v2(list) +} + unsafe fn box_info( handle: *mut BoxHandle, out_info: *mut *mut CBoxInfo, @@ -194,6 +377,27 @@ unsafe fn box_info( } } +unsafe fn box_info_v2( + handle: *mut BoxHandle, + out_info: *mut *mut CBoxInfoV2, + out_error: *mut FFIError, +) -> BoxliteErrorCode { + unsafe { + if handle.is_null() { + write_error(out_error, null_pointer_error("handle")); + return BoxliteErrorCode::InvalidArgument; + } + if out_info.is_null() { + write_error(out_error, null_pointer_error("out_info")); + return BoxliteErrorCode::InvalidArgument; + } + + let info = (*handle).handle.info(); + *out_info = Box::into_raw(Box::new(CBoxInfoV2::from_box_info(&info))); + BoxliteErrorCode::Ok + } +} + unsafe fn box_info_by_id( runtime: *mut RuntimeHandle, id_or_name: *const c_char, @@ -247,6 +451,58 @@ unsafe fn box_info_by_id( } } +unsafe fn box_info_by_id_v2( + runtime: *mut RuntimeHandle, + id_or_name: *const c_char, + cb: CBoxInfoV2Cb, + user_data: *mut c_void, + out_error: *mut FFIError, +) -> BoxliteErrorCode { + unsafe { + if runtime.is_null() { + write_error(out_error, null_pointer_error("runtime")); + return BoxliteErrorCode::InvalidArgument; + } + + let id_or_name = match crate::util::c_str_to_string(id_or_name) { + Ok(value) => value, + Err(error) => { + write_error(out_error, error); + return BoxliteErrorCode::InvalidArgument; + } + }; + let cb = crate::unwrap_cb_or_return!(cb, out_error); + + let runtime = &*runtime; + let runtime_clone = runtime.runtime.clone(); + let queue = runtime.queue.clone(); + let user_data = user_data as usize; + runtime.tokio_rt.spawn(async move { + let result = match runtime_clone.get_info(&id_or_name).await { + Ok(Some(info)) => Ok(crate::event_queue::OwnedFfiPtr::new_with( + Box::new(CBoxInfoV2::from_box_info(&info)), + free_box_info_v2_ptr, + )), + Ok(None) => Err(BoxliteError::NotFound(format!( + "Box not found: {id_or_name}" + ))), + Err(error) => Err(error), + }; + push_event( + &queue, + RuntimeEvent::InfoV2 { + cb, + user_data, + result, + }, + ) + .await; + }); + + BoxliteErrorCode::Ok + } +} + unsafe fn box_list( runtime: *mut RuntimeHandle, cb: CBoxInfoListCb, @@ -267,10 +523,19 @@ unsafe fn box_list( runtime_ref.tokio_rt.spawn(async move { let result = runtime_clone.list_info().await.map(|boxes| { - let mut items: Vec = boxes.iter().map(CBoxInfo::from_box_info).collect(); + let mut items = boxes + .iter() + .map(CBoxInfo::from_box_info) + .collect::>() + .into_boxed_slice(); let count = items.len() as c_int; - let ptr = items.as_mut_ptr(); - std::mem::forget(items); + let ptr = if items.is_empty() { + ptr::null_mut() + } else { + let ptr = items.as_mut_ptr(); + Box::leak(items); + ptr + }; crate::event_queue::OwnedFfiPtr::new_with( Box::new(CBoxInfoList { items: ptr, count }), free_box_info_list, @@ -290,3 +555,117 @@ unsafe fn box_list( BoxliteErrorCode::Ok } } + +unsafe fn box_list_v2( + runtime: *mut RuntimeHandle, + cb: CBoxInfoListV2Cb, + user_data: *mut c_void, + out_error: *mut FFIError, +) -> BoxliteErrorCode { + unsafe { + if runtime.is_null() { + write_error(out_error, null_pointer_error("runtime")); + return BoxliteErrorCode::InvalidArgument; + } + let cb = crate::unwrap_cb_or_return!(cb, out_error); + + let runtime = &*runtime; + let runtime_clone = runtime.runtime.clone(); + let queue = runtime.queue.clone(); + let user_data = user_data as usize; + runtime.tokio_rt.spawn(async move { + let result = runtime_clone.list_info().await.map(|boxes| { + let mut items = boxes + .iter() + .map(CBoxInfoV2::from_box_info) + .collect::>() + .into_boxed_slice(); + let count = items.len() as c_int; + let items_ptr = if items.is_empty() { + ptr::null_mut() + } else { + let items_ptr = items.as_mut_ptr(); + Box::leak(items); + items_ptr + }; + crate::event_queue::OwnedFfiPtr::new_with( + Box::new(CBoxInfoListV2 { + items: items_ptr, + count, + }), + free_box_info_list_v2, + ) + }); + push_event( + &queue, + RuntimeEvent::InfoListV2 { + cb, + user_data, + result, + }, + ) + .await; + }); + + BoxliteErrorCode::Ok + } +} + +#[cfg(test)] +mod tests { + use super::*; + use boxlite::{BoxAdvancedInfo, BoxID, BoxInfo, ContainerCapabilities, HealthStatus}; + use std::collections::HashMap; + use std::ffi::CStr; + + #[test] + fn box_info_v2_preserves_capability_policy() { + let _free_str_guard = crate::FREE_STR_LOCK.lock().unwrap(); + let free_str_calls_before = crate::FREE_STR_CALLS.load(std::sync::atomic::Ordering::SeqCst); + let now = "2026-01-01T00:00:00Z".parse().unwrap(); + let source = BoxInfo { + id: BoxID::parse("c-info-v2").unwrap(), + name: Some("custom-policy".into()), + status: BoxStatus::Configured, + created_at: now, + last_updated: now, + pid: None, + image: "alpine:latest".into(), + cpus: 1, + memory_mib: 512, + advanced: BoxAdvancedInfo { + capabilities: ContainerCapabilities { + add: vec!["NET_ADMIN".into()], + drop: vec!["NET_RAW".into(), "MKNOD".into()], + }, + }, + labels: HashMap::new(), + auto_pause: 0, + auto_delete: 0, + auto_resume: true, + health_status: HealthStatus::new(), + exit_code: None, + }; + + let info = Box::into_raw(Box::new(CBoxInfoV2::from_box_info(&source))); + unsafe { + assert_eq!((*info).advanced.capabilities.add_count, 1); + assert_eq!((*info).advanced.capabilities.drop_count, 2); + assert_eq!( + CStr::from_ptr(*(*info).advanced.capabilities.add) + .to_str() + .unwrap(), + "NET_ADMIN" + ); + assert_eq!( + CStr::from_ptr(*(*info).advanced.capabilities.drop.add(1)) + .to_str() + .unwrap(), + "MKNOD" + ); + free_box_info_v2_ptr(info); + } + let free_str_calls_after = crate::FREE_STR_CALLS.load(std::sync::atomic::Ordering::SeqCst); + assert_eq!(free_str_calls_after - free_str_calls_before, 7); + } +} diff --git a/sdks/c/src/lib.rs b/sdks/c/src/lib.rs index f6efa5970..9f52a7a4e 100644 --- a/sdks/c/src/lib.rs +++ b/sdks/c/src/lib.rs @@ -56,6 +56,10 @@ pub type CBoxliteError = error::FFIError; pub type CBoxliteExecResult = exec::ExecResult; pub type CBoxInfo = info::CBoxInfo; pub type CBoxInfoList = info::CBoxInfoList; +pub type CContainerCapabilities = info::CContainerCapabilities; +pub type CBoxAdvancedInfo = info::CBoxAdvancedInfo; +pub type CBoxInfoV2 = info::CBoxInfoV2; +pub type CBoxInfoListV2 = info::CBoxInfoListV2; pub type CBoxMetrics = metrics::CBoxMetrics; pub type CExecutionHandle = exec::ExecutionHandle; pub type CImageInfoList = images::CImageInfoList; diff --git a/sdks/c/src/options.rs b/sdks/c/src/options.rs index fe133468f..d01130f47 100644 --- a/sdks/c/src/options.rs +++ b/sdks/c/src/options.rs @@ -190,7 +190,7 @@ pub unsafe extern "C" fn boxlite_options_set_detach(opts: *mut CBoxliteOptions, options_set_detach(opts, val) } -/// Apply a `CAdvancedBoxOptions` (security, mount isolation, health check) to a +/// Apply a `CAdvancedBoxOptions` (capabilities, security, mount isolation, health check) to a /// `CBoxliteOptions`. Clones the advanced configuration into the box options — /// the caller retains ownership of `advanced_opts` and is responsible for /// freeing it via `boxlite_advanced_options_free`. diff --git a/sdks/c/src/runtime.rs b/sdks/c/src/runtime.rs index 6073b363e..03a632ddc 100644 --- a/sdks/c/src/runtime.rs +++ b/sdks/c/src/runtime.rs @@ -616,6 +616,16 @@ unsafe fn dispatch_event(event: RuntimeEvent) { user_data, result, } => dispatch_handle_event::(result, user_data, cb), + RuntimeEvent::InfoV2 { + cb, + user_data, + result, + } => dispatch_handle_event::(result, user_data, cb), + RuntimeEvent::InfoListV2 { + cb, + user_data, + result, + } => dispatch_handle_event::(result, user_data, cb), RuntimeEvent::Metrics { cb, user_data, diff --git a/sdks/c/src/tests.rs b/sdks/c/src/tests.rs index 2405b7951..63946def0 100644 --- a/sdks/c/src/tests.rs +++ b/sdks/c/src/tests.rs @@ -423,6 +423,160 @@ fn auto_remove_and_auto_delete_use_last_call_wins() { } } +#[test] +fn capability_lists_default_empty_and_preserve_custom_values() { + let image = CString::new("alpine:latest").expect("image cstring"); + let mut opts: *mut CBoxliteOptions = ptr::null_mut(); + let mut advanced: *mut CAdvancedBoxOptions = ptr::null_mut(); + let mut error = FFIError::default(); + assert_eq!( + unsafe { boxlite_options_new(image.as_ptr(), &mut opts, &mut error) }, + BoxliteErrorCode::Ok + ); + assert_eq!( + unsafe { boxlite_advanced_options_new(&mut advanced, &mut error) }, + BoxliteErrorCode::Ok + ); + + unsafe { + assert!((*advanced).options.capabilities.add.is_empty()); + assert!((*advanced).options.capabilities.drop.is_empty()); + } + + let cap_add = [ + CString::new("NET_ADMIN").unwrap(), + CString::new("SYS_PTRACE").unwrap(), + ]; + let cap_add_ptrs: Vec<*const std::os::raw::c_char> = + cap_add.iter().map(|cap| cap.as_ptr()).collect(); + let cap_drop = [ + CString::new("MKNOD").unwrap(), + CString::new("NET_RAW").unwrap(), + ]; + let cap_drop_ptrs: Vec<*const std::os::raw::c_char> = + cap_drop.iter().map(|cap| cap.as_ptr()).collect(); + + unsafe { + assert_eq!( + boxlite_advanced_options_set_capabilities_add( + advanced, + cap_add_ptrs.as_ptr(), + cap_add_ptrs.len() as c_int, + ), + BoxliteErrorCode::Ok + ); + assert_eq!( + boxlite_advanced_options_set_capabilities_drop( + advanced, + cap_drop_ptrs.as_ptr(), + cap_drop_ptrs.len() as c_int + ), + BoxliteErrorCode::Ok + ); + boxlite_options_set_advanced(opts, advanced); + + assert_eq!( + (*opts).options.advanced.capabilities.add, + ["NET_ADMIN", "SYS_PTRACE"] + ); + assert_eq!( + (*opts).options.advanced.capabilities.drop, + ["MKNOD", "NET_RAW"] + ); + boxlite_advanced_options_free(advanced); + boxlite_options_free(opts); + } +} + +#[test] +fn null_capability_element_cannot_weaken_policy() { + let image = CString::new("alpine:latest").unwrap(); + let mut opts: *mut CBoxliteOptions = ptr::null_mut(); + let mut advanced: *mut CAdvancedBoxOptions = ptr::null_mut(); + let mut error = FFIError::default(); + assert_eq!( + unsafe { boxlite_options_new(image.as_ptr(), &mut opts, &mut error) }, + BoxliteErrorCode::Ok + ); + assert_eq!( + unsafe { boxlite_advanced_options_new(&mut advanced, &mut error) }, + BoxliteErrorCode::Ok + ); + + let malformed = [ptr::null()]; + unsafe { + assert_eq!( + boxlite_advanced_options_set_capabilities_drop(advanced, malformed.as_ptr(), 1), + BoxliteErrorCode::InvalidArgument + ); + boxlite_options_set_advanced(opts, advanced); + (*opts) + .options + .sanitize() + .expect_err("a null cap_drop element must fail closed"); + boxlite_advanced_options_free(advanced); + boxlite_options_free(opts); + } +} + +#[test] +fn invalid_utf8_capability_cannot_weaken_policy() { + let image = CString::new("alpine:latest").unwrap(); + let mut opts: *mut CBoxliteOptions = ptr::null_mut(); + let mut advanced: *mut CAdvancedBoxOptions = ptr::null_mut(); + let mut error = FFIError::default(); + assert_eq!( + unsafe { boxlite_options_new(image.as_ptr(), &mut opts, &mut error) }, + BoxliteErrorCode::Ok + ); + assert_eq!( + unsafe { boxlite_advanced_options_new(&mut advanced, &mut error) }, + BoxliteErrorCode::Ok + ); + + let invalid_utf8 = [0xff_u8, 0]; + let malformed = [invalid_utf8.as_ptr().cast::()]; + unsafe { + assert_eq!( + boxlite_advanced_options_set_capabilities_add(advanced, malformed.as_ptr(), 1), + BoxliteErrorCode::InvalidArgument + ); + boxlite_options_set_advanced(opts, advanced); + (*opts) + .options + .sanitize() + .expect_err("invalid UTF-8 in cap_add must fail closed"); + boxlite_advanced_options_free(advanced); + boxlite_options_free(opts); + } +} + +#[test] +fn invalid_capability_count_and_null_array_fail_closed() { + let mut advanced: *mut CAdvancedBoxOptions = ptr::null_mut(); + let mut error = FFIError::default(); + assert_eq!( + unsafe { boxlite_advanced_options_new(&mut advanced, &mut error) }, + BoxliteErrorCode::Ok + ); + + unsafe { + assert_eq!( + boxlite_advanced_options_set_capabilities_add(advanced, ptr::null(), -1), + BoxliteErrorCode::InvalidArgument + ); + assert_eq!( + boxlite_advanced_options_set_capabilities_drop(advanced, ptr::null(), 1), + BoxliteErrorCode::InvalidArgument + ); + assert_eq!( + boxlite_advanced_options_set_capabilities_add(ptr::null_mut(), ptr::null(), 0), + BoxliteErrorCode::InvalidArgument + ); + boxlite_advanced_options_free(advanced); + } +} + // Security is toggled through the advanced layer: // `boxlite_advanced_options_set_security_enabled` selects the enabled/disabled // profile on a `CAdvancedBoxOptions`, then `boxlite_options_set_advanced` diff --git a/sdks/go/README.md b/sdks/go/README.md index 9dc1459cc..6f9fa45a7 100644 --- a/sdks/go/README.md +++ b/sdks/go/README.md @@ -96,6 +96,21 @@ for _, image := range cached { - `WithNetwork(boxlite.NetworkSpec{Mode: boxlite.NetworkModeEnabled, AllowNet: []string{"api.openai.com"}})` restricts outbound traffic while keeping networking enabled. - `WithNetwork(boxlite.NetworkSpec{Mode: boxlite.NetworkModeDisabled})` disables the guest network interface entirely. - `WithSecret(boxlite.Secret{...})` configures host-side HTTP(S) secret substitution; `Placeholder` defaults to ``. +- Container capabilities live under advanced options: + + ```go + advanced, err := boxlite.NewAdvancedBoxOptions() + if err != nil { log.Fatal(err) } + defer advanced.Close() + if err := advanced.SetCapabilities(boxlite.ContainerCapabilities{ + Add: []string{"NET_ADMIN"}, + Drop: []string{"NET_RAW"}, + }); err != nil { log.Fatal(err) } + box, err := runtime.Create(ctx, "alpine:latest", boxlite.WithAdvancedOptions(advanced)) + ``` + +Inspection uses the same grouping: `info.Advanced.Capabilities.Add` and +`info.Advanced.Capabilities.Drop`. ## Development diff --git a/sdks/go/advanced_options.go b/sdks/go/advanced_options.go index 69792c63a..99847602b 100644 --- a/sdks/go/advanced_options.go +++ b/sdks/go/advanced_options.go @@ -1,10 +1,8 @@ -// AdvancedBoxOptions groups the box-level advanced knobs (currently the -// security toggle) under one handle, mirroring core `BoxOptions.advanced`. +// AdvancedBoxOptions groups box-level capability and security knobs under one +// handle, mirroring core `BoxOptions.advanced`. // -// Build it via `NewAdvancedBoxOptions`, toggle the sandbox with -// `SetSecurityEnabled`, and pass it to `runtime.Create(..., WithAdvancedOptions(adv))`. -// Security is reached through this layer (never attached to the box directly), -// matching the core `BoxOptions.advanced.security` model. +// Build it via `NewAdvancedBoxOptions`, configure capabilities or security, +// and pass it to `runtime.Create(..., WithAdvancedOptions(adv))`. // // adv, _ := boxlite.NewAdvancedBoxOptions() // defer adv.Close() @@ -18,13 +16,24 @@ package boxlite */ import "C" -import "runtime" +import ( + "fmt" + "runtime" +) + +// ContainerCapabilities is the requested Linux capability policy. +// Capability names may be written with or without the CAP_ prefix. +type ContainerCapabilities struct { + Add []string + Drop []string +} // AdvancedBoxOptions is the Go-side handle for a `CAdvancedBoxOptions`. // Construct via `NewAdvancedBoxOptions`; release via `Close` once it has // been attached to a box (or you no longer need it). type AdvancedBoxOptions struct { - handle *C.CAdvancedBoxOptions + handle *C.CAdvancedBoxOptions + capabilities ContainerCapabilities } // NewAdvancedBoxOptions allocates an advanced-options handle initialized to @@ -52,6 +61,41 @@ func (a *AdvancedBoxOptions) SetSecurityEnabled(enabled bool) { C.boxlite_advanced_options_set_security_enabled(a.handle, boolToCInt(enabled)) } +// SetCapabilities replaces advanced.capabilities for subsequently created +// boxes. The input slices are copied; callers may safely reuse or mutate them +// after this method returns. +func (a *AdvancedBoxOptions) SetCapabilities(capabilities ContainerCapabilities) error { + if a == nil || a.handle == nil { + return fmt.Errorf("boxlite: advanced options handle is closed") + } + if err := validateCapabilities("advanced.capabilities.add", capabilities.Add); err != nil { + return err + } + if err := validateCapabilities("advanced.capabilities.drop", capabilities.Drop); err != nil { + return err + } + + add, addCount := toCStringArray(capabilities.Add) + addCode := C.boxlite_advanced_options_set_capabilities_add(a.handle, add, C.int(addCount)) + freeCStringArray(add, addCount) + if addCode != C.Ok { + return fmt.Errorf("boxlite: invalid advanced.capabilities.add") + } + + drop, dropCount := toCStringArray(capabilities.Drop) + dropCode := C.boxlite_advanced_options_set_capabilities_drop(a.handle, drop, C.int(dropCount)) + freeCStringArray(drop, dropCount) + if dropCode != C.Ok { + return fmt.Errorf("boxlite: invalid advanced.capabilities.drop") + } + + a.capabilities = ContainerCapabilities{ + Add: append([]string(nil), capabilities.Add...), + Drop: append([]string(nil), capabilities.Drop...), + } + return nil +} + // Close releases the underlying CAdvancedBoxOptions. Idempotent. func (a *AdvancedBoxOptions) Close() { if a == nil || a.handle == nil { @@ -59,5 +103,6 @@ func (a *AdvancedBoxOptions) Close() { } C.boxlite_advanced_options_free(a.handle) a.handle = nil + a.capabilities = ContainerCapabilities{} runtime.SetFinalizer(a, nil) } diff --git a/sdks/go/boxlite_test.go b/sdks/go/boxlite_test.go index 77001fe9d..486f6ce1d 100644 --- a/sdks/go/boxlite_test.go +++ b/sdks/go/boxlite_test.go @@ -2,6 +2,7 @@ package boxlite import ( "errors" + "reflect" "testing" "unsafe" ) @@ -270,6 +271,73 @@ func TestBoxOptions(t *testing.T) { } } +func TestAdvancedOptionsSetCapabilitiesDeepCopies(t *testing.T) { + advanced, err := NewAdvancedBoxOptions() + if err != nil { + t.Fatalf("NewAdvancedBoxOptions: %v", err) + } + defer advanced.Close() + + policy := ContainerCapabilities{ + Add: []string{"NET_ADMIN", "SYS_PTRACE"}, + Drop: []string{"MKNOD", "NET_RAW"}, + } + if err := advanced.SetCapabilities(policy); err != nil { + t.Fatalf("SetCapabilities: %v", err) + } + policy.Add[0] = "CHOWN" + policy.Drop[0] = "SETUID" + + wantAdd := []string{"NET_ADMIN", "SYS_PTRACE"} + wantDrop := []string{"MKNOD", "NET_RAW"} + if !reflect.DeepEqual(advanced.capabilities.Add, wantAdd) { + t.Fatalf("advanced.capabilities.add: got %v, want %v", advanced.capabilities.Add, wantAdd) + } + if !reflect.DeepEqual(advanced.capabilities.Drop, wantDrop) { + t.Fatalf("advanced.capabilities.drop: got %v, want %v", advanced.capabilities.Drop, wantDrop) + } + + cfg := &boxConfig{} + WithAdvancedOptions(advanced)(cfg) + if err := buildAndFreeCOptions("alpine:latest", cfg); err != nil { + t.Fatalf("nested capability options must apply cleanly: %v", err) + } +} + +func TestSetCapabilitiesRejectsEmbeddedNUL(t *testing.T) { + advanced, err := NewAdvancedBoxOptions() + if err != nil { + t.Fatalf("NewAdvancedBoxOptions: %v", err) + } + defer advanced.Close() + + err = advanced.SetCapabilities(ContainerCapabilities{Add: []string{"SYS_ADMIN\x00garbage"}}) + if err == nil { + t.Fatal("embedded NUL in a capability must not be truncated into a valid capability") + } +} + +func TestSetCapabilitiesReturnsInvalidArgumentForMalformedNames(t *testing.T) { + advanced, err := NewAdvancedBoxOptions() + if err != nil { + t.Fatalf("NewAdvancedBoxOptions: %v", err) + } + defer advanced.Close() + + err = advanced.SetCapabilities(ContainerCapabilities{Add: []string{"NET-ADMIN"}}) + if err == nil { + t.Fatal("malformed capability must be rejected") + } + + var boxliteErr *Error + if !errors.As(err, &boxliteErr) { + t.Fatalf("expected *Error, got %T: %v", err, err) + } + if boxliteErr.Code != ErrInvalidArgument { + t.Fatalf("Code: got %d, want %d", boxliteErr.Code, ErrInvalidArgument) + } +} + func TestWithPortExplicitSpec(t *testing.T) { cfg := &boxConfig{} WithPort(PortSpec{ diff --git a/sdks/go/bridge.c b/sdks/go/bridge.c index a8b27ce8c..203002274 100644 --- a/sdks/go/bridge.c +++ b/sdks/go/bridge.c @@ -27,8 +27,8 @@ extern void goBoxliteOnVolume(CVolumeInfo *info, CBoxliteError *err, void *ud); extern void goBoxliteOnVolumeList(CVolumeInfoList *list, CBoxliteError *err, void *ud); extern void goBoxliteOnVolumeRemove(CBoxliteError *err, void *ud); -extern void goBoxliteOnInfo(CBoxInfo *info, CBoxliteError *err, void *ud); -extern void goBoxliteOnInfoList(CBoxInfoList *list, CBoxliteError *err, void *ud); +extern void goBoxliteOnInfoV2(CBoxInfoV2 *info, CBoxliteError *err, void *ud); +extern void goBoxliteOnInfoListV2(CBoxInfoListV2 *list, CBoxliteError *err, void *ud); extern void goBoxliteOnBoxMetrics(CBoxMetrics *m, CBoxliteError *err, void *ud); extern void goBoxliteOnRuntimeMetrics(CRuntimeMetrics *m, CBoxliteError *err, void *ud); @@ -62,8 +62,8 @@ CBoxVolumeGetCb cbVolumeGet(void) { return (CBoxVolumeGetCb)goBoxliteOnVolume; } CBoxVolumeListCb cbVolumeList(void) { return (CBoxVolumeListCb)goBoxliteOnVolumeList; } CBoxVolumeRemoveCb cbVolumeRemove(void) { return (CBoxVolumeRemoveCb)goBoxliteOnVolumeRemove; } -CBoxInfoCb cbInfo(void) { return (CBoxInfoCb)goBoxliteOnInfo; } -CBoxInfoListCb cbInfoList(void) { return (CBoxInfoListCb)goBoxliteOnInfoList; } +CBoxInfoV2Cb cbInfoV2(void) { return (CBoxInfoV2Cb)goBoxliteOnInfoV2; } +CBoxInfoListV2Cb cbInfoListV2(void) { return (CBoxInfoListV2Cb)goBoxliteOnInfoListV2; } CBoxMetricsCb cbBoxMetrics(void) { return (CBoxMetricsCb)goBoxliteOnBoxMetrics; } CRuntimeMetricsCb cbRuntimeMetrics(void) { return (CRuntimeMetricsCb)goBoxliteOnRuntimeMetrics; } diff --git a/sdks/go/bridge.h b/sdks/go/bridge.h index 0cee21cc8..72dc24d44 100644 --- a/sdks/go/bridge.h +++ b/sdks/go/bridge.h @@ -27,8 +27,8 @@ extern CBoxVolumeListCb cbVolumeList(void); extern CBoxVolumeGetCb cbVolumeGet(void); extern CBoxVolumeRemoveCb cbVolumeRemove(void); -extern CBoxInfoCb cbInfo(void); -extern CBoxInfoListCb cbInfoList(void); +extern CBoxInfoV2Cb cbInfoV2(void); +extern CBoxInfoListV2Cb cbInfoListV2(void); extern CBoxMetricsCb cbBoxMetrics(void); extern CRuntimeMetricsCb cbRuntimeMetrics(void); diff --git a/sdks/go/bridge_callback.go b/sdks/go/bridge_callback.go index 953adc2c6..d5e8c0090 100644 --- a/sdks/go/bridge_callback.go +++ b/sdks/go/bridge_callback.go @@ -307,15 +307,15 @@ func goBoxliteOnVolumeRemove(errPtr *C.CBoxliteError, userData unsafe.Pointer) { // ─── Info callbacks ──────────────────────────────────────────────────────── -//export goBoxliteOnInfo -func goBoxliteOnInfo(info *C.CBoxInfo, errPtr *C.CBoxliteError, userData unsafe.Pointer) { +//export goBoxliteOnInfoV2 +func goBoxliteOnInfoV2(info *C.CBoxInfoV2, errPtr *C.CBoxliteError, userData unsafe.Pointer) { h := ptrToHandle(userData) if h == 0 { return } - if !claimOrFreePayload(h, &info, func(i **C.CBoxInfo) { + if !claimOrFreePayload(h, &info, func(i **C.CBoxInfoV2) { if i != nil && *i != nil { - C.boxlite_free_box_info(*i) + C.boxlite_free_box_info_v2(*i) } }) { return @@ -333,20 +333,20 @@ func goBoxliteOnInfo(info *C.CBoxInfo, errPtr *C.CBoxliteError, userData unsafe. ch <- infoResult{} return } - v := cBoxInfoToGo(info) - C.boxlite_free_box_info(info) + v := cBoxInfoV2ToGo(info) + C.boxlite_free_box_info_v2(info) ch <- infoResult{value: &v} } -//export goBoxliteOnInfoList -func goBoxliteOnInfoList(list *C.CBoxInfoList, errPtr *C.CBoxliteError, userData unsafe.Pointer) { +//export goBoxliteOnInfoListV2 +func goBoxliteOnInfoListV2(list *C.CBoxInfoListV2, errPtr *C.CBoxliteError, userData unsafe.Pointer) { h := ptrToHandle(userData) if h == 0 { return } - if !claimOrFreePayload(h, &list, func(l **C.CBoxInfoList) { + if !claimOrFreePayload(h, &list, func(l **C.CBoxInfoListV2) { if l != nil && *l != nil { - C.boxlite_free_box_info_list(*l) + C.boxlite_free_box_info_list_v2(*l) } }) { return @@ -360,9 +360,9 @@ func goBoxliteOnInfoList(list *C.CBoxInfoList, errPtr *C.CBoxliteError, userData ch <- infoListResult{err: err} return } - out := convertBoxInfoList(list) + out := convertBoxInfoListV2(list) if list != nil { - C.boxlite_free_box_info_list(list) + C.boxlite_free_box_info_list_v2(list) } ch <- infoListResult{value: out} } diff --git a/sdks/go/info.go b/sdks/go/info.go index fab7af28a..337f14397 100644 --- a/sdks/go/info.go +++ b/sdks/go/info.go @@ -22,6 +22,10 @@ const ( ) // BoxInfo holds information about a box. +type BoxAdvancedInfo struct { + Capabilities ContainerCapabilities +} + type BoxInfo struct { ID string Name string @@ -34,6 +38,7 @@ type BoxInfo struct { AutoPause uint32 AutoDelete uint32 AutoResume bool + Advanced BoxAdvancedInfo CreatedAt time.Time } @@ -42,15 +47,15 @@ type BoxInfo struct { // boxlite_box_info is synchronous on the C side (it reads cached fields on // the handle), so no drain participation is required. func (b *Box) Info(_ context.Context) (*BoxInfo, error) { - var cInfo *C.CBoxInfo + var cInfo *C.CBoxInfoV2 var cerr C.CBoxliteError - code := C.boxlite_box_info(b.handle, &cInfo, &cerr) + code := C.boxlite_box_info_v2(b.handle, &cInfo, &cerr) if code != C.Ok { return nil, freeError(&cerr) } - defer C.boxlite_free_box_info(cInfo) + defer C.boxlite_free_box_info_v2(cInfo) - info := cBoxInfoToGo(cInfo) + info := cBoxInfoV2ToGo(cInfo) if info.Name != "" && b.name == "" { b.name = info.Name } @@ -65,7 +70,7 @@ func (r *Runtime) ListInfo(ctx context.Context) ([]BoxInfo, error) { h := registerHandleForDispatch(cgo.NewHandle(ch)) var cerr C.CBoxliteError - code := C.boxlite_list_info(r.handle, C.cbInfoList(), handleToPtr(h), &cerr) + code := C.boxlite_list_info_v2(r.handle, C.cbInfoListV2(), handleToPtr(h), &cerr) if code != C.Ok { deleteHandleForDispatch(h) return nil, freeError(&cerr) @@ -94,7 +99,7 @@ func (r *Runtime) GetInfo(ctx context.Context, idOrName string) (*BoxInfo, error h := registerHandleForDispatch(cgo.NewHandle(ch)) var cerr C.CBoxliteError - code := C.boxlite_get_info(r.handle, cID, C.cbInfo(), handleToPtr(h), &cerr) + code := C.boxlite_get_info_v2(r.handle, cID, C.cbInfoV2(), handleToPtr(h), &cerr) if code != C.Ok { deleteHandleForDispatch(h) return nil, freeError(&cerr) @@ -112,34 +117,59 @@ func (r *Runtime) GetInfo(ctx context.Context, idOrName string) (*BoxInfo, error } } -func cBoxInfoToGo(info *C.CBoxInfo) BoxInfo { - pid := int(info.pid) +func cBoxInfoV2ToGo(info *C.CBoxInfoV2) BoxInfo { + base := &info.base + pid := int(base.pid) return BoxInfo{ - ID: cString(info.id), - Name: cString(info.name), - Image: cString(info.image), - State: State(cString(info.status)), - Running: info.running != 0, + ID: cString(base.id), + Name: cString(base.name), + Image: cString(base.image), + State: State(cString(base.status)), + Running: base.running != 0, PID: pid, - CPUs: int(info.cpus), - MemoryMiB: int(info.memory_mib), - AutoPause: uint32(info.auto_pause), - AutoDelete: uint32(info.auto_delete), - AutoResume: info.auto_resume != 0, - CreatedAt: time.Unix(int64(info.created_at), 0), + CPUs: int(base.cpus), + MemoryMiB: int(base.memory_mib), + AutoPause: uint32(base.auto_pause), + AutoDelete: uint32(base.auto_delete), + AutoResume: base.auto_resume != 0, + Advanced: BoxAdvancedInfo{ + Capabilities: ContainerCapabilities{ + Add: cStringList( + info.advanced.capabilities.add, + int(info.advanced.capabilities.add_count), + ), + Drop: cStringList( + info.advanced.capabilities.drop, + int(info.advanced.capabilities.drop_count), + ), + }, + }, + CreatedAt: time.Unix(int64(base.created_at), 0), + } +} + +func cStringList(values **C.char, count int) []string { + if values == nil || count == 0 { + return nil + } + cValues := unsafe.Slice(values, count) + result := make([]string, len(cValues)) + for index, value := range cValues { + result[index] = cString(value) } + return result } -// convertBoxInfoList materialises a CBoxInfoList* into Go BoxInfo slice. +// convertBoxInfoListV2 materialises a CBoxInfoListV2* into Go BoxInfo slice. // The caller is responsible for freeing the C list afterwards. -func convertBoxInfoList(list *C.CBoxInfoList) []BoxInfo { +func convertBoxInfoListV2(list *C.CBoxInfoListV2) []BoxInfo { if list == nil || list.count == 0 || list.items == nil { return nil } items := unsafe.Slice(list.items, int(list.count)) out := make([]BoxInfo, len(items)) for i := range items { - out[i] = cBoxInfoToGo(&items[i]) + out[i] = cBoxInfoV2ToGo(&items[i]) } return out } diff --git a/sdks/go/options.go b/sdks/go/options.go index dc2f645af..eb112c199 100644 --- a/sdks/go/options.go +++ b/sdks/go/options.go @@ -343,14 +343,13 @@ func buildAndFreeCOptions(image string, cfg *boxConfig) error { return nil } -// WithAdvancedOptions attaches advanced box options (currently the security -// toggle) to the box. Security is reached through this layer, mirroring the -// core `BoxOptions.advanced.security` model. +// WithAdvancedOptions attaches advanced capability and security options to the +// box, mirroring the core `BoxOptions.advanced` model. // -// Build the handle via NewAdvancedBoxOptions and toggle the sandbox with -// SetSecurityEnabled. The caller retains ownership and must call `adv.Close()` -// after the box has been created (or sooner, if discarded). If never called, -// the box uses the defaults (the fully-isolated security profile). +// Build the handle via NewAdvancedBoxOptions and configure it with +// SetCapabilities and/or SetSecurityEnabled. The caller retains ownership and +// must call `adv.Close()` after the box has been created (or sooner, if +// discarded). If never called, the box uses the defaults. // // adv, _ := boxlite.NewAdvancedBoxOptions() // defer adv.Close() @@ -503,7 +502,7 @@ func buildCOptions(image string, cfg *boxConfig) (*C.CBoxliteOptions, error) { C.boxlite_options_set_detach(cOpts, boolToCInt(*cfg.detach)) } if cfg.advanced != nil && cfg.advanced.handle != nil { - // Clone the caller-owned advanced options (security, …) onto the box. + // Clone the caller-owned advanced options onto the box. // The Go-side handle stays caller-owned; the box has its own copy after // set_advanced returns. C.boxlite_options_set_advanced(cOpts, cfg.advanced.handle) @@ -518,10 +517,36 @@ func buildCOptions(image string, cfg *boxConfig) (*C.CBoxliteOptions, error) { C.boxlite_options_set_cmd(cOpts, cArgs, C.int(argc)) freeCStringArray(cArgs, argc) } - return cOpts, nil } +func validateCapabilities(option string, capabilities []string) error { + for _, capability := range capabilities { + normalized := strings.ToUpper(capability) + if normalized == "ALL" { + continue + } + name := strings.TrimPrefix(normalized, "CAP_") + if name == "" { + return &Error{ + Code: ErrInvalidArgument, + Message: fmt.Sprintf("empty Linux capability in %s", option), + } + } + for index, character := range []byte(name) { + isLetter := character >= 'A' && character <= 'Z' + isTail := isLetter || character >= '0' && character <= '9' || character == '_' + if index == 0 && !isLetter || index > 0 && !isTail { + return &Error{ + Code: ErrInvalidArgument, + Message: fmt.Sprintf("malformed Linux capability in %s: %q", option, capability), + } + } + } + } + return nil +} + func boolToCInt(v bool) C.int { if v { return 1 diff --git a/sdks/go/runtime.go b/sdks/go/runtime.go index c32f9cae0..6503f03d5 100644 --- a/sdks/go/runtime.go +++ b/sdks/go/runtime.go @@ -191,6 +191,8 @@ func (r *Runtime) Create(ctx context.Context, image string, opts ...BoxOption) ( // The second return value, created, is true when a new box was created and // false when an existing box was adopted — letting callers skip one-time // initialization for an adopted box. +// General options are ignored when adopting an existing box, but +// AdvancedBoxOptions capabilities must match its persisted security policy. // // On context cancellation it only frees the returned handle (like Get); it // never force-removes the box, because an adopted box may be one the caller did diff --git a/sdks/node/README.md b/sdks/node/README.md index 8bcdb8fab..e88476ce1 100644 --- a/sdks/node/README.md +++ b/sdks/node/README.md @@ -182,6 +182,12 @@ const box = new SimpleBox({ mode: 'enabled', allowNet: ['api.openai.com'], }, + advanced: { + capabilities: { + add: ['NET_ADMIN'], + drop: ['NET_RAW'], + }, + }, env: { FOO: 'bar' }, volumes: [ { hostPath: '/tmp/data', guestPath: '/data', readOnly: false } @@ -214,6 +220,7 @@ console.log(pwdResult.stdout); // "/tmp\n" console.log(box.id); // ULID console.log(box.name); // Optional name console.log(box.info()); // Metadata +// box.info().advanced.capabilities.add / .drop // Cleanup await box.stop(); diff --git a/sdks/node/lib/index.ts b/sdks/node/lib/index.ts index e7a8886a2..ea08d6e11 100644 --- a/sdks/node/lib/index.ts +++ b/sdks/node/lib/index.ts @@ -98,6 +98,8 @@ export { BoxTunnel, NetworkHandle, type NetworkSpec, + type AdvancedBoxOptions, + type ContainerCapabilities, type SimpleBoxOptions, type SecurityOptions, type Secret, diff --git a/sdks/node/lib/native-contracts.ts b/sdks/node/lib/native-contracts.ts index 6cc70cf4f..0fd7f95b3 100644 --- a/sdks/node/lib/native-contracts.ts +++ b/sdks/node/lib/native-contracts.ts @@ -121,6 +121,15 @@ export interface JsHealthCheckOptions { startPeriod: number; } +export interface JsContainerCapabilities { + add?: string[]; + drop?: string[]; +} + +export interface JsAdvancedBoxOptions { + capabilities?: JsContainerCapabilities; +} + export interface JsBoxOptions { image?: string; rootfsPath?: string; @@ -148,6 +157,7 @@ export interface JsBoxOptions { entrypoint?: string[]; cmd?: string[]; user?: string; + advanced?: JsAdvancedBoxOptions; security?: JsSecurityOptions; healthCheck?: JsHealthCheckOptions; secrets?: JsSecret[]; @@ -218,6 +228,15 @@ export interface JsBoxStateInfo { pid?: number; } +export interface JsContainerCapabilitiesInfo { + add: string[]; + drop: string[]; +} + +export interface JsBoxAdvancedInfo { + capabilities: JsContainerCapabilitiesInfo; +} + export interface JsBoxInfo { id: string; name?: string; @@ -226,6 +245,7 @@ export interface JsBoxInfo { image: string; cpus: number; memoryMib: number; + advanced: JsBoxAdvancedInfo; autoPause: number; autoDelete: number; autoResume: boolean; diff --git a/sdks/node/lib/simplebox.ts b/sdks/node/lib/simplebox.ts index a581c0143..ea95c8971 100644 --- a/sdks/node/lib/simplebox.ts +++ b/sdks/node/lib/simplebox.ts @@ -262,10 +262,24 @@ export interface SimpleBoxOptions { */ user?: string; + /** Expert-only container process options. */ + advanced?: AdvancedBoxOptions; + /** Security isolation options for the box. */ security?: SecurityOptions; } +export interface ContainerCapabilities { + /** Linux capabilities added to BoxLite's Docker-compatible baseline. */ + add?: string[]; + /** Linux capabilities removed from the resulting capability set. */ + drop?: string[]; +} + +export interface AdvancedBoxOptions { + capabilities?: ContainerCapabilities; +} + /** Box-scoped network operations for a SimpleBox. */ export class NetworkHandle { /** @internal */ @@ -405,6 +419,16 @@ export class SimpleBox { entrypoint: options.entrypoint, cmd: options.cmd, user: options.user, + advanced: options.advanced + ? { + capabilities: options.advanced.capabilities + ? { + add: [...(options.advanced.capabilities.add ?? [])], + drop: [...(options.advanced.capabilities.drop ?? [])], + } + : undefined, + } + : undefined, security, secrets: options.secrets, }; diff --git a/sdks/node/src/advanced_options.rs b/sdks/node/src/advanced_options.rs index 511ce84e2..b2d3f54c7 100644 --- a/sdks/node/src/advanced_options.rs +++ b/sdks/node/src/advanced_options.rs @@ -1,4 +1,4 @@ -use boxlite::runtime::advanced_options::{ResourceLimits, SecurityOptions}; +use boxlite::runtime::advanced_options::{ContainerCapabilities, ResourceLimits, SecurityOptions}; use napi_derive::napi; // ============================================================================ @@ -89,6 +89,34 @@ impl From for SecurityOptions { } } +/// Linux capability policy for the container process. +#[napi(object)] +#[derive(Clone, Debug)] +pub struct JsContainerCapabilities { + /// Capabilities added to BoxLite's Docker-compatible baseline. + pub add: Option>, + + /// Capabilities removed from the resulting capability set. + pub drop: Option>, +} + +impl From for ContainerCapabilities { + fn from(capabilities: JsContainerCapabilities) -> Self { + Self { + add: capabilities.add.unwrap_or_default(), + drop: capabilities.drop.unwrap_or_default(), + } + } +} + +/// Expert-only box options. Released top-level security and health-check +/// fields remain on `JsBoxOptions`; new capability policy is nested here. +#[napi(object)] +#[derive(Clone, Debug)] +pub struct JsAdvancedBoxOptions { + pub capabilities: Option, +} + #[cfg(test)] mod tests { use super::*; diff --git a/sdks/node/src/info.rs b/sdks/node/src/info.rs index b5644122e..dcdf1cad2 100644 --- a/sdks/node/src/info.rs +++ b/sdks/node/src/info.rs @@ -95,6 +95,21 @@ impl From for JsBoxStateInfo { // BoxInfo - Container info with nested state // ============================================================================ +/// Linux capability policy returned by box inspection. +#[napi(object)] +#[derive(Clone, Debug)] +pub struct JsContainerCapabilitiesInfo { + pub add: Vec, + pub drop: Vec, +} + +/// Expert-only, inspection-safe metadata about a box. +#[napi(object)] +#[derive(Clone, Debug)] +pub struct JsBoxAdvancedInfo { + pub capabilities: JsContainerCapabilitiesInfo, +} + /// Public metadata about a box (returned by list operations). /// /// Provides read-only information about a box's identity, configuration, @@ -123,6 +138,9 @@ pub struct JsBoxInfo { /// Allocated memory in MiB pub memory_mib: u32, + /// Expert-only inspection metadata. + pub advanced: JsBoxAdvancedInfo, + /// Idle time in seconds before AutoPause; 0 disables it. #[napi(js_name = "autoPause")] pub auto_pause: u32, @@ -148,6 +166,12 @@ impl From for JsBoxInfo { failures: info.health_status.failures, last_check: info.health_status.last_check.map(|dt| dt.to_rfc3339()), }; + let advanced = JsBoxAdvancedInfo { + capabilities: JsContainerCapabilitiesInfo { + add: info.advanced.capabilities.add, + drop: info.advanced.capabilities.drop, + }, + }; Self { id: info.id.to_string(), @@ -157,6 +181,7 @@ impl From for JsBoxInfo { image: info.image, cpus: info.cpus, memory_mib: info.memory_mib, + advanced, auto_pause: info.auto_pause, auto_delete: info.auto_delete, auto_resume: info.auto_resume, diff --git a/sdks/node/src/lib.rs b/sdks/node/src/lib.rs index 5683d8daa..cd1f8d832 100644 --- a/sdks/node/src/lib.rs +++ b/sdks/node/src/lib.rs @@ -21,12 +21,15 @@ mod util; mod volumes; // Re-export all public types -pub use advanced_options::JsSecurityOptions; +pub use advanced_options::{JsAdvancedBoxOptions, JsContainerCapabilities, JsSecurityOptions}; pub use box_handle::JsBox; pub use copy::JsCopyOptions; pub use exec::{JsExecResult, JsExecStderr, JsExecStdin, JsExecStdout, JsExecution}; pub use images::{JsImageHandle, JsImageInfo, JsImagePullResult}; -pub use info::{JsBoxInfo, JsBoxStateInfo, JsHealthState, JsHealthStatus}; +pub use info::{ + JsBoxAdvancedInfo, JsBoxInfo, JsBoxStateInfo, JsContainerCapabilitiesInfo, JsHealthState, + JsHealthStatus, +}; pub use metrics::{JsBoxMetrics, JsRuntimeMetrics}; pub use network::{JsBoxConnection, JsBoxTunnel, JsNetworkHandle}; pub use options::{ diff --git a/sdks/node/src/options.rs b/sdks/node/src/options.rs index ab47cc2c1..38e93152a 100644 --- a/sdks/node/src/options.rs +++ b/sdks/node/src/options.rs @@ -10,7 +10,9 @@ use boxlite::runtime::options::{ use napi::bindgen_prelude::Error; use napi_derive::napi; -use crate::advanced_options::JsSecurityOptions; +#[cfg(test)] +use crate::advanced_options::JsContainerCapabilities; +use crate::advanced_options::{JsAdvancedBoxOptions, JsSecurityOptions}; /// Health check options for boxes. /// @@ -229,6 +231,9 @@ pub struct JsBoxOptions { /// If None, uses the image's USER directive (defaults to root). pub user: Option, + /// Expert-only options introduced after the original flat API. + pub advanced: Option, + /// Security isolation options for the box. pub security: Option, @@ -408,6 +413,11 @@ impl TryFrom for BoxOptions { .unwrap_or_default(); let health_check = js_opts.health_check.map(HealthCheckOptions::from); + let capabilities = js_opts + .advanced + .and_then(|advanced| advanced.capabilities) + .map(Into::into) + .unwrap_or_default(); let secrets = js_opts .secrets .unwrap_or_default() @@ -434,6 +444,7 @@ impl TryFrom for BoxOptions { ports, auto_remove, advanced: AdvancedBoxOptions { + capabilities, security, health_check, ..Default::default() @@ -733,6 +744,7 @@ mod tests { entrypoint: None, cmd: None, user: None, + advanced: None, security: None, health_check: None, secrets: None, @@ -745,9 +757,28 @@ mod tests { assert!(!both.auto_remove); assert_eq!(both.auto_delete, Some(60)); + let mut with_capabilities = js.clone(); + with_capabilities.advanced = Some(JsAdvancedBoxOptions { + capabilities: Some(JsContainerCapabilities { + add: Some(vec!["NET_ADMIN".into(), "SYS_PTRACE".into()]), + drop: Some(vec!["MKNOD".into(), "NET_RAW".into()]), + }), + }); + let with_capabilities = BoxOptions::try_from(with_capabilities).unwrap(); + assert_eq!( + with_capabilities.advanced.capabilities.add, + ["NET_ADMIN", "SYS_PTRACE"] + ); + assert_eq!( + with_capabilities.advanced.capabilities.drop, + ["MKNOD", "NET_RAW"] + ); + let opts = BoxOptions::try_from(js).unwrap(); assert!(!opts.auto_remove); assert_eq!(opts.auto_delete, None); + assert!(opts.advanced.capabilities.add.is_empty()); + assert!(opts.advanced.capabilities.drop.is_empty()); match opts.network { NetworkSpec::Enabled { allow_net } => { assert_eq!(allow_net, vec!["example.com", "*.openai.com"]); @@ -777,6 +808,7 @@ mod tests { entrypoint: None, cmd: None, user: None, + advanced: None, security: None, health_check: None, secrets: Some(vec![JsSecret { diff --git a/sdks/node/src/runtime.rs b/sdks/node/src/runtime.rs index ac4c268a1..644eb841a 100644 --- a/sdks/node/src/runtime.rs +++ b/sdks/node/src/runtime.rs @@ -145,7 +145,8 @@ impl JsBoxlite { /// Returns an object with `box` (the box handle) and `created` (true if /// newly created, false if an existing box was found). /// - /// When an existing box is returned, the provided options are ignored. + /// When an existing box is returned, general options are ignored, but + /// `advanced.capabilities` must match its persisted security policy. /// /// # Arguments /// * `options` - Box configuration (used only if creating a new box) diff --git a/sdks/node/tests/options.test.ts b/sdks/node/tests/options.test.ts index b5ed88c00..6c0017ccb 100644 --- a/sdks/node/tests/options.test.ts +++ b/sdks/node/tests/options.test.ts @@ -1,12 +1,18 @@ /** * Unit tests for SimpleBoxOptions interface (no VM required). * - * Tests the type structure and expected properties for cmd/user options. + * Tests the type structure and expected properties for box options. */ -import { describe, test, expect } from "vitest"; +import { describe, test, expect, vi } from "vitest"; import type { Secret, SimpleBoxOptions } from "../lib/simplebox.js"; +vi.mock("../lib/native.js", () => ({ + getJsBoxlite: () => ({ + withDefaultConfig: () => ({}), + }), +})); + describe("SimpleBoxOptions", () => { test("cmd defaults to undefined", () => { const opts: SimpleBoxOptions = {}; @@ -18,6 +24,36 @@ describe("SimpleBoxOptions", () => { expect(opts.user).toBeUndefined(); }); + test("capability policy defaults to undefined", async () => { + const { SimpleBox } = await import("../lib/simplebox.js"); + const box = new SimpleBox(); + const nativeOptions = (box as any)._boxOpts; + + expect(nativeOptions.advanced).toBeUndefined(); + }); + + test("forwards custom capability lists", async () => { + const { SimpleBox } = await import("../lib/simplebox.js"); + const box = new SimpleBox({ + advanced: { + capabilities: { + add: ["NET_ADMIN", "SYS_PTRACE"], + drop: ["MKNOD", "NET_RAW"], + }, + }, + }); + const nativeOptions = (box as any)._boxOpts; + + expect(nativeOptions.advanced.capabilities.add).toEqual([ + "NET_ADMIN", + "SYS_PTRACE", + ]); + expect(nativeOptions.advanced.capabilities.drop).toEqual([ + "MKNOD", + "NET_RAW", + ]); + }); + test("accepts cmd array", () => { const opts: SimpleBoxOptions = { image: "docker:dind", diff --git a/sdks/python/README.md b/sdks/python/README.md index af1f62049..cf7c09705 100644 --- a/sdks/python/README.md +++ b/sdks/python/README.md @@ -242,6 +242,9 @@ Configuration options for creating a box. - `ports: List[Tuple[int, int, str]]` - Port forwarding as (host_port, guest_port, protocol) - Protocol: `"tcp"` or `"udp"` - `secrets: List[Secret]` - Host-side HTTP(S) secret substitution rules +- `advanced: AdvancedBoxOptions | None` - Expert-only container options + - `capabilities.add: List[str]` - Capabilities added to BoxLite's baseline + - `capabilities.drop: List[str]` - Capabilities removed from the resulting set - `auto_remove: bool` - Auto cleanup after stop (default: True) `NetworkSpec` uses: @@ -273,6 +276,12 @@ options = boxlite.BoxOptions( mode="enabled", allow_net=["api.openai.com"], ), + advanced=boxlite.AdvancedBoxOptions( + capabilities=boxlite.ContainerCapabilities( + add=["NET_ADMIN"], + drop=["NET_RAW"], + ), + ), secrets=[ boxlite.Secret( name="openai", @@ -284,6 +293,9 @@ options = boxlite.BoxOptions( box = runtime.create(options) ``` +Inspection returns the effective policy at +`info.advanced.capabilities.add` and `info.advanced.capabilities.drop`. + ### Box Handle #### `boxlite.Box` diff --git a/sdks/python/boxlite/__init__.py b/sdks/python/boxlite/__init__.py index 786809ca9..9cfdb1cbc 100644 --- a/sdks/python/boxlite/__init__.py +++ b/sdks/python/boxlite/__init__.py @@ -10,7 +10,9 @@ try: from .boxlite import ( AccessToken, + AdvancedBoxOptions, ApiKeyCredential, + BoxAdvancedInfo, Box, BoxInfo, Boxlite, @@ -20,6 +22,7 @@ BoxStateInfo, CloneOptions, CopyOptions, + ContainerCapabilities, ExecStderr, ExecStdout, Execution, @@ -47,6 +50,8 @@ __all__ = [ # noqa: RUF022 - grouped by API area, not alphabetical # Core Rust API "Options", + "AdvancedBoxOptions", + "ContainerCapabilities", "ImageRegistry", "BoxOptions", "BoxliteRestOptions", @@ -63,6 +68,7 @@ "ImageInfo", "ImagePullResult", "BoxInfo", + "BoxAdvancedInfo", "BoxStateInfo", "HealthState", "HealthStatus", diff --git a/sdks/python/boxlite/sync_api/_boxlite.py b/sdks/python/boxlite/sync_api/_boxlite.py index cb96bbf38..12aed1233 100644 --- a/sdks/python/boxlite/sync_api/_boxlite.py +++ b/sdks/python/boxlite/sync_api/_boxlite.py @@ -263,7 +263,9 @@ def create( Create a new box. Args: - options: BoxOptions specifying image, resources, etc. + options: BoxOptions specifying image, resources, etc. General + options are ignored when a box is reused, but + advanced.capabilities must match its persisted security policy. name: Optional unique name for the box. Returns: diff --git a/sdks/python/src/advanced_options.rs b/sdks/python/src/advanced_options.rs index acaa025d7..517dbbece 100644 --- a/sdks/python/src/advanced_options.rs +++ b/sdks/python/src/advanced_options.rs @@ -1,4 +1,6 @@ -use boxlite::runtime::advanced_options::{HealthCheckOptions, ResourceLimits, SecurityOptions}; +use boxlite::runtime::advanced_options::{ + ContainerCapabilities, HealthCheckOptions, ResourceLimits, SecurityOptions, +}; use pyo3::prelude::*; // ============================================================================ @@ -253,6 +255,44 @@ impl From for SecurityOptions { // Advanced Options // ============================================================================ +/// Linux capability policy for the container process. +#[pyclass(name = "ContainerCapabilities")] +#[derive(Clone, Debug, Default)] +pub struct PyContainerCapabilities { + /// Capabilities added to BoxLite's Docker-compatible baseline. + #[pyo3(get, set)] + pub add: Vec, + + /// Capabilities removed from the resulting capability set. + #[pyo3(get, set)] + pub drop: Vec, +} + +#[pymethods] +impl PyContainerCapabilities { + #[new] + #[pyo3(signature = (add=vec![], drop=vec![]))] + fn new(add: Vec, drop: Vec) -> Self { + Self { add, drop } + } + + fn __repr__(&self) -> String { + format!( + "ContainerCapabilities(add={:?}, drop={:?})", + self.add, self.drop + ) + } +} + +impl From for ContainerCapabilities { + fn from(capabilities: PyContainerCapabilities) -> Self { + Self { + add: capabilities.add, + drop: capabilities.drop, + } + } +} + /// Advanced options for expert users. /// /// Entry-level users can ignore this — defaults are compatibility-focused. @@ -266,19 +306,25 @@ pub struct PyAdvancedBoxOptions { /// Health check options. #[pyo3(get, set)] pub health_check: Option, + + /// Linux capability policy for the container process. + #[pyo3(get, set)] + pub capabilities: PyContainerCapabilities, } #[pymethods] impl PyAdvancedBoxOptions { #[new] - #[pyo3(signature = (security=None, health_check=None))] + #[pyo3(signature = (security=None, health_check=None, capabilities=None))] fn new( security: Option, health_check: Option, + capabilities: Option, ) -> Self { Self { security, health_check, + capabilities: capabilities.unwrap_or_default(), } } } diff --git a/sdks/python/src/info.rs b/sdks/python/src/info.rs index 240de374f..5395bda93 100644 --- a/sdks/python/src/info.rs +++ b/sdks/python/src/info.rs @@ -1,6 +1,8 @@ use boxlite::{BoxInfo, BoxStateInfo, BoxStatus, HealthState as CoreHealthState}; use pyo3::prelude::*; +use crate::advanced_options::PyContainerCapabilities; + // ============================================================================ // HealthState - Health check state enumeration // ============================================================================ @@ -159,6 +161,27 @@ impl From for PyBoxStateInfo { // BoxInfo - Container info with nested state // ============================================================================ +/// Expert-only, inspection-safe metadata about a box. +#[pyclass(name = "BoxAdvancedInfo")] +#[derive(Clone)] +pub(crate) struct PyBoxAdvancedInfo { + #[pyo3(get)] + pub(crate) capabilities: PyContainerCapabilities, +} + +#[pymethods] +impl PyBoxAdvancedInfo { + fn __repr__(&self) -> String { + serde_json::to_string_pretty(&serde_json::json!({ + "capabilities": { + "add": self.capabilities.add, + "drop": self.capabilities.drop, + } + })) + .unwrap_or_default() + } +} + #[pyclass(name = "BoxInfo")] #[derive(Clone)] pub(crate) struct PyBoxInfo { @@ -177,6 +200,8 @@ pub(crate) struct PyBoxInfo { #[pyo3(get)] pub(crate) memory_mib: u32, #[pyo3(get)] + pub(crate) advanced: PyBoxAdvancedInfo, + #[pyo3(get)] pub(crate) auto_pause: u32, #[pyo3(get)] pub(crate) auto_delete: u32, @@ -200,6 +225,12 @@ impl PyBoxInfo { "image": self.image, "cpus": self.cpus, "memory_mib": self.memory_mib, + "advanced": { + "capabilities": { + "add": self.advanced.capabilities.add, + "drop": self.advanced.capabilities.drop, + } + }, "auto_pause": self.auto_pause, "auto_delete": self.auto_delete, "auto_resume": self.auto_resume, @@ -223,6 +254,12 @@ impl From for PyBoxInfo { failures: info.health_status.failures, last_check: info.health_status.last_check.map(|dt| dt.to_rfc3339()), }; + let advanced = PyBoxAdvancedInfo { + capabilities: PyContainerCapabilities { + add: info.advanced.capabilities.add, + drop: info.advanced.capabilities.drop, + }, + }; PyBoxInfo { id: info.id.to_string(), @@ -232,6 +269,7 @@ impl From for PyBoxInfo { image: info.image, cpus: info.cpus, memory_mib: info.memory_mib, + advanced, auto_pause: info.auto_pause, auto_delete: info.auto_delete, auto_resume: info.auto_resume, diff --git a/sdks/python/src/lib.rs b/sdks/python/src/lib.rs index 6ff730b17..fbe37295f 100644 --- a/sdks/python/src/lib.rs +++ b/sdks/python/src/lib.rs @@ -14,11 +14,13 @@ mod snapshots; mod util; mod volumes; -use crate::advanced_options::{PyAdvancedBoxOptions, PyHealthCheckOptions, PySecurityOptions}; +use crate::advanced_options::{ + PyAdvancedBoxOptions, PyContainerCapabilities, PyHealthCheckOptions, PySecurityOptions, +}; use crate::box_handle::PyBox; use crate::exec::{PyExecStderr, PyExecStdin, PyExecStdout, PyExecution}; use crate::images::{PyImageHandle, PyImageInfo, PyImagePullResult}; -use crate::info::{PyBoxInfo, PyBoxStateInfo, PyHealthState, PyHealthStatus}; +use crate::info::{PyBoxAdvancedInfo, PyBoxInfo, PyBoxStateInfo, PyHealthState, PyHealthStatus}; use crate::metrics::{PyBoxMetrics, PyRuntimeMetrics}; use crate::network::{PyBoxConnection, PyBoxTunnel, PyNetworkHandle}; use crate::options::{ @@ -40,6 +42,7 @@ fn boxlite_python(m: &Bound<'_, PyModule>) -> PyResult<()> { m.add_class::()?; m.add_class::()?; m.add_class::()?; + m.add_class::()?; m.add_class::()?; m.add_class::()?; m.add_class::()?; @@ -52,6 +55,7 @@ fn boxlite_python(m: &Bound<'_, PyModule>) -> PyResult<()> { m.add_class::()?; m.add_class::()?; m.add_class::()?; + m.add_class::()?; m.add_class::()?; m.add_class::()?; m.add_class::()?; diff --git a/sdks/python/src/options.rs b/sdks/python/src/options.rs index 9518650c6..4f0fd2638 100644 --- a/sdks/python/src/options.rs +++ b/sdks/python/src/options.rs @@ -408,7 +408,7 @@ pub(crate) struct PyBoxOptions { #[pyo3(get, set)] pub(crate) user: Option, - /// Advanced options for expert users (security, mount isolation, health check). + /// Advanced options for expert users (capabilities, security, mount isolation, health check). #[pyo3(get, set)] pub(crate) advanced: Option, @@ -564,6 +564,7 @@ impl TryFrom for BoxOptions { if let Some(health_check) = advanced.health_check { opts.advanced.health_check = Some(HealthCheckOptions::from(health_check)); } + opts.advanced.capabilities = advanced.capabilities.into(); } // Convert Python secrets to Rust secrets diff --git a/sdks/python/tests/test_options.py b/sdks/python/tests/test_options.py index ad27cba20..4f344c2bb 100644 --- a/sdks/python/tests/test_options.py +++ b/sdks/python/tests/test_options.py @@ -36,6 +36,27 @@ def test_detach_default_is_none(self): # Python side defaults to None, Rust side defaults to False assert opts.detach is None + def test_capability_lists_default_to_empty(self): + """Test that capability overrides are empty under advanced options.""" + advanced = boxlite.AdvancedBoxOptions() + assert advanced.capabilities.add == [] + assert advanced.capabilities.drop == [] + + def test_custom_capability_lists_are_preserved(self): + """Test supplying Docker-style capability additions and removals.""" + capabilities = boxlite.ContainerCapabilities( + add=["NET_ADMIN", "SYS_PTRACE"], + drop=["MKNOD", "NET_RAW"], + ) + opts = boxlite.BoxOptions( + image="alpine:latest", + advanced=boxlite.AdvancedBoxOptions(capabilities=capabilities), + ) + assert opts.advanced.capabilities.add == ["NET_ADMIN", "SYS_PTRACE"] + assert opts.advanced.capabilities.drop == ["MKNOD", "NET_RAW"] + assert not hasattr(opts, "cap_add") + assert not hasattr(opts, "cap_drop") + def test_explicit_auto_remove_true(self): """Test setting auto_remove=True explicitly.""" opts = boxlite.BoxOptions(image="alpine:latest", auto_remove=True) diff --git a/src/boxlite/src/db/migration/mod.rs b/src/boxlite/src/db/migration/mod.rs index 60609c5f0..2e3ba0236 100644 --- a/src/boxlite/src/db/migration/mod.rs +++ b/src/boxlite/src/db/migration/mod.rs @@ -10,6 +10,7 @@ mod v4_to_v5; mod v5_to_v6; mod v6_to_v7; mod v7_to_v8; +mod v8_to_v9; use std::path::Path; @@ -79,5 +80,6 @@ fn all_migrations() -> Vec> { Box::new(v5_to_v6::ReplaceSnapshots), Box::new(v6_to_v7::MoveDisksAndAddBaseDisk), Box::new(v7_to_v8::RenameNetworkSpec), + Box::new(v8_to_v9::GuardCapabilityPolicy), ] } diff --git a/src/boxlite/src/db/migration/v8_to_v9.rs b/src/boxlite/src/db/migration/v8_to_v9.rs new file mode 100644 index 000000000..7cef4e074 --- /dev/null +++ b/src/boxlite/src/db/migration/v8_to_v9.rs @@ -0,0 +1,34 @@ +//! Migration v8 → v9: establish the capability-policy compatibility boundary. +//! +//! `BoxOptions` is stored as JSON, so no row rewrite is needed. Bumping the +//! schema prevents a v8 binary—which does not understand +//! `advanced.capabilities`—from opening a database after a newer binary may +//! have persisted that policy and silently restoring a weaker policy. + +use std::path::Path; + +use rusqlite::Connection; + +use boxlite_shared::errors::BoxliteResult; + +use super::Migration; + +pub(crate) struct GuardCapabilityPolicy; + +impl Migration for GuardCapabilityPolicy { + fn source_version(&self) -> i32 { + 8 + } + + fn target_version(&self) -> i32 { + 9 + } + + fn description(&self) -> &str { + "Require capability-aware readers for persisted BoxOptions" + } + + fn run(&self, _conn: &Connection, _home_dir: Option<&Path>) -> BoxliteResult<()> { + Ok(()) + } +} diff --git a/src/boxlite/src/db/mod.rs b/src/boxlite/src/db/mod.rs index 340fdc0c7..26c063184 100644 --- a/src/boxlite/src/db/mod.rs +++ b/src/boxlite/src/db/mod.rs @@ -200,6 +200,35 @@ mod tests { assert!(tables.contains(&"snapshot".to_string())); } + #[test] + fn test_db_migration_v8_to_v9() { + let temp_dir = TempDir::new().unwrap(); + let db_path = temp_dir.path().join("test.db"); + + { + let conn = Connection::open(&db_path).unwrap(); + conn.execute_batch(schema::SCHEMA_VERSION_TABLE).unwrap(); + let now = Utc::now().to_rfc3339(); + conn.execute( + "INSERT INTO schema_version (id, version, updated_at) VALUES (1, 8, ?1)", + rusqlite::params![now], + ) + .unwrap(); + } + + let db = Database::open(&db_path).unwrap(); + let version: i32 = db + .conn() + .query_row( + "SELECT version FROM schema_version WHERE id = 1", + [], + |row| row.get(0), + ) + .unwrap(); + + assert_eq!(version, 9); + } + #[test] fn test_db_migration_v4_to_v7() { let temp_dir = TempDir::new().unwrap(); diff --git a/src/boxlite/src/db/schema.rs b/src/boxlite/src/db/schema.rs index e450b8c8c..c419be10f 100644 --- a/src/boxlite/src/db/schema.rs +++ b/src/boxlite/src/db/schema.rs @@ -7,7 +7,7 @@ //! Each table has queryable columns for efficient filtering + JSON blob for full data. /// Current schema version. -pub const SCHEMA_VERSION: i32 = 8; +pub const SCHEMA_VERSION: i32 = 9; /// Schema version tracking table. pub const SCHEMA_VERSION_TABLE: &str = r#" diff --git a/src/boxlite/src/lib.rs b/src/boxlite/src/lib.rs index 8dc4c1eac..c1e6c5cc9 100644 --- a/src/boxlite/src/lib.rs +++ b/src/boxlite/src/lib.rs @@ -48,7 +48,7 @@ pub use litebox::{ }; pub use metrics::{BoxMetrics, RuntimeMetrics}; pub use runtime::advanced_options::{ - AdvancedBoxOptions, HealthCheckOptions, ResourceLimits, SecurityOptions, + AdvancedBoxOptions, ContainerCapabilities, HealthCheckOptions, ResourceLimits, SecurityOptions, }; pub use runtime::options::{ BoxArchive, BoxOptions, BoxliteOptions, CloneOptions, ExportOptions, ImageRegistry, @@ -58,7 +58,9 @@ pub use runtime::options::{ pub const VERSION: &str = env!("CARGO_PKG_VERSION"); pub use runtime::id::{BaseDiskID, BaseDiskIDMint, BoxID, BoxIDMint}; pub use runtime::types::ContainerID; -pub use runtime::types::{BoxInfo, BoxLifecyclePolicy, BoxState, BoxStateInfo, BoxStatus}; +pub use runtime::types::{ + BoxAdvancedInfo, BoxInfo, BoxLifecyclePolicy, BoxState, BoxStateInfo, BoxStatus, +}; #[cfg(feature = "rest")] pub use rest::credential::{AccessToken, ApiKeyCredential, Credential}; diff --git a/src/boxlite/src/litebox/archive.rs b/src/boxlite/src/litebox/archive.rs index 641fdce91..5a4e0a88f 100644 --- a/src/boxlite/src/litebox/archive.rs +++ b/src/boxlite/src/litebox/archive.rs @@ -15,20 +15,37 @@ use crate::disk::constants::filenames as disk_filenames; /// Manifest filename inside the archive. pub(crate) const MANIFEST_FILENAME: &str = "manifest.json"; -/// Current archive format version. +/// Baseline archive format version for configurations representable by v3. pub(crate) const ARCHIVE_VERSION: u32 = 3; +/// First archive version that preserves a custom Linux capability policy. +pub(crate) const CAPABILITY_POLICY_ARCHIVE_VERSION: u32 = 4; + /// Maximum archive version this build can import. -pub(crate) const MAX_SUPPORTED_VERSION: u32 = 3; +pub(crate) const MAX_SUPPORTED_VERSION: u32 = CAPABILITY_POLICY_ARCHIVE_VERSION; + +/// Select the archive format for a box configuration. +/// +/// Kept as a function so fields added to [`crate::runtime::options::BoxOptions`] +/// can opt into a newer compatibility boundary without needlessly changing +/// archives that only use the v3 representation. +pub(crate) fn archive_version_for_options(options: &crate::runtime::options::BoxOptions) -> u32 { + if options.advanced.capabilities.is_empty() { + ARCHIVE_VERSION + } else { + CAPABILITY_POLICY_ARCHIVE_VERSION + } +} /// Archive manifest stored as `manifest.json` inside exported archives. /// /// v1: plain tar, no checksums /// v2: tar.zst with checksums /// v3: adds `box_options` for full configuration preservation +/// v4: `box_options.advanced` may include a custom capability policy #[derive(Debug, Serialize, Deserialize)] pub struct ArchiveManifest { - /// Archive format version (1, 2, or 3). + /// Archive format version (1 through 4). pub version: u32, /// Original box name (optional, may be renamed on import). pub box_name: Option, @@ -239,6 +256,24 @@ mod tests { use super::*; use tempfile::tempdir; + #[test] + fn capability_policy_uses_archive_v4() { + let ordinary = crate::runtime::options::BoxOptions::default(); + assert_eq!(archive_version_for_options(&ordinary), 3); + + let custom = crate::runtime::options::BoxOptions { + advanced: crate::runtime::advanced_options::AdvancedBoxOptions { + capabilities: crate::runtime::advanced_options::ContainerCapabilities { + drop: vec!["NET_RAW".into()], + ..Default::default() + }, + ..Default::default() + }, + ..Default::default() + }; + assert_eq!(archive_version_for_options(&custom), 4); + } + #[test] fn test_extract_zstd_archive_via_magic_bytes() { let dir = tempdir().unwrap(); diff --git a/src/boxlite/src/litebox/clone_export.rs b/src/boxlite/src/litebox/clone_export.rs index 452afcd31..15fe3f8d9 100644 --- a/src/boxlite/src/litebox/clone_export.rs +++ b/src/boxlite/src/litebox/clone_export.rs @@ -287,7 +287,8 @@ fn do_export_finalize( dest: &std::path::Path, ) -> BoxliteResult { use super::archive::{ - ARCHIVE_VERSION, ArchiveManifest, MANIFEST_FILENAME, build_zstd_tar_archive, sha256_file, + ArchiveManifest, MANIFEST_FILENAME, archive_version_for_options, build_zstd_tar_archive, + sha256_file, }; let output_path = if dest.is_dir() { @@ -311,7 +312,7 @@ fn do_export_finalize( }; let manifest = ArchiveManifest { - version: ARCHIVE_VERSION, + version: archive_version_for_options(config_options), box_name: config_name.map(|s| s.to_string()), image, box_options: Some(config_options.clone()), diff --git a/src/boxlite/src/litebox/init/tasks/guest_init.rs b/src/boxlite/src/litebox/init/tasks/guest_init.rs index 574048587..f3a31d8ce 100644 --- a/src/boxlite/src/litebox/init/tasks/guest_init.rs +++ b/src/boxlite/src/litebox/init/tasks/guest_init.rs @@ -7,84 +7,87 @@ //! the vmm_config stage. use super::{InitCtx, log_task_error, task_start}; -use crate::images::ContainerImageConfig; use crate::net::constants::{GATEWAY_IP, GUEST_CIDR, GUEST_INTERFACE}; use crate::pipeline::PipelineTask; use crate::portal::GuestSession; -use crate::portal::interfaces::{ContainerRootfsInitConfig, GuestInitConfig, NetworkInitConfig}; -use crate::runtime::options::NetworkSpec; -use crate::runtime::types::ContainerID; -use crate::volumes::{ContainerMount, GuestVolumeManager}; +use crate::portal::interfaces::{ContainerInitConfig, GuestInitConfig, NetworkInitConfig}; use async_trait::async_trait; use boxlite_shared::errors::{BoxliteError, BoxliteResult}; pub struct GuestInitTask; +struct GuestBootstrapConfig { + guest: GuestInitConfig, + container: ContainerInitConfig, +} + #[async_trait] impl PipelineTask for GuestInitTask { async fn run(self: Box, ctx: InitCtx) -> BoxliteResult<()> { let task_name = self.name(); let box_id = task_start(&ctx, task_name).await; - let ( - guest_session, - container_image_config, - container_id, - volume_mgr, - rootfs_init, - container_mounts, - network_spec, - ca_cert_pem, - tty, - ) = - { - let mut ctx = ctx.lock().await; - let guest_session = ctx - .guest_session - .take() - .ok_or_else(|| BoxliteError::Internal("connect task must run first".into()))?; - let container_image_config = ctx - .container_image_config - .clone() - .ok_or_else(|| BoxliteError::Internal("rootfs task must run first".into()))?; - let volume_mgr = ctx.volume_mgr.take().ok_or_else(|| { - BoxliteError::Internal("vmm_spawn task must run first".into()) - })?; - let rootfs_init = ctx.rootfs_init.take().ok_or_else(|| { - BoxliteError::Internal("vmm_spawn task must run first".into()) - })?; - let container_mounts = ctx.container_mounts.take().ok_or_else(|| { - BoxliteError::Internal("vmm_spawn task must run first".into()) - })?; - let network_spec = ctx.config.options.network.clone(); - let ca_cert_pem = ctx.ca_cert_pem.clone(); - let tty = ctx.config.options.tty; - ( - guest_session, - container_image_config, - ctx.config.container.id.clone(), - volume_mgr, - rootfs_init, - container_mounts, - network_spec, - ca_cert_pem, - tty, - ) + let (guest_session, volume_mgr, rootfs_init, container_mounts, bootstrap) = { + let mut ctx = ctx.lock().await; + let guest_session = ctx + .guest_session + .take() + .ok_or_else(|| BoxliteError::Internal("connect task must run first".into()))?; + let image = ctx + .container_image_config + .clone() + .ok_or_else(|| BoxliteError::Internal("rootfs task must run first".into()))?; + let volume_mgr = ctx + .volume_mgr + .take() + .ok_or_else(|| BoxliteError::Internal("vmm_spawn task must run first".into()))?; + let rootfs_init = ctx + .rootfs_init + .take() + .ok_or_else(|| BoxliteError::Internal("vmm_spawn task must run first".into()))?; + let container_mounts = ctx + .container_mounts + .take() + .ok_or_else(|| BoxliteError::Internal("vmm_spawn task must run first".into()))?; + + let network = match &ctx.config.options.network { + crate::runtime::options::NetworkSpec::Enabled { .. } => Some(NetworkInitConfig { + interface: GUEST_INTERFACE.to_string(), + ip: Some(GUEST_CIDR.to_string()), + gateway: Some(GATEWAY_IP.to_string()), + }), + crate::runtime::options::NetworkSpec::Disabled => None, + }; + let bootstrap = GuestBootstrapConfig { + guest: GuestInitConfig { + volumes: volume_mgr.build_guest_mounts(), + network, + }, + container: ContainerInitConfig { + container_id: ctx.config.container.id.as_str().to_owned(), + image, + rootfs: rootfs_init.clone(), + mounts: container_mounts.clone(), + ca_certs: ctx.ca_cert_pem.iter().cloned().collect(), + tty: ctx.config.options.tty, + advanced: crate::portal::interfaces::container::ContainerAdvancedConfig { + capabilities: ctx.config.options.advanced.capabilities.clone(), + }, + }, }; - run_guest_init( - guest_session.clone(), - &container_image_config, - &container_id, - &volume_mgr, - &rootfs_init, - &container_mounts, - &network_spec, - ca_cert_pem.as_deref(), - tty, - ) - .await - .inspect_err(|e| log_task_error(&box_id, task_name, e))?; + ( + guest_session, + volume_mgr, + rootfs_init, + container_mounts, + bootstrap, + ) + }; + + run_guest_init(guest_session.clone(), bootstrap) + .await + .inspect_err(|e| log_task_error(&box_id, task_name, e))?; let mut ctx = ctx.lock().await; ctx.guest_session = Some(guest_session); @@ -101,58 +104,26 @@ impl PipelineTask for GuestInitTask { } /// Initialize the guest and create the container (init is *not* run here). -#[allow(clippy::too_many_arguments)] async fn run_guest_init( guest_session: GuestSession, - container_image_config: &ContainerImageConfig, - container_id: &ContainerID, - volume_mgr: &GuestVolumeManager, - rootfs_init: &ContainerRootfsInitConfig, - container_mounts: &[ContainerMount], - network_spec: &NetworkSpec, - ca_cert_pem: Option<&str>, - tty: bool, + bootstrap: GuestBootstrapConfig, ) -> BoxliteResult<()> { - let container_id_str = container_id.as_str(); - - // Build guest volumes from volume manager - let guest_volumes = volume_mgr.build_guest_mounts(); - - let network = match network_spec { - NetworkSpec::Enabled { .. } => Some(NetworkInitConfig { - interface: GUEST_INTERFACE.to_string(), - ip: Some(GUEST_CIDR.to_string()), - gateway: Some(GATEWAY_IP.to_string()), - }), - NetworkSpec::Disabled => None, - }; - - let guest_init_config = GuestInitConfig { - volumes: guest_volumes, - network, - }; - // Step 1: Guest Init (volumes + network) tracing::info!("Sending guest initialization request"); let mut guest_interface = guest_session.guest().await?; - guest_interface.init(guest_init_config).await?; + if !bootstrap.container.advanced.capabilities.is_empty() { + guest_interface + .require_feature(boxlite_shared::constants::guest_features::LINUX_CAPABILITIES_V2) + .await?; + } + guest_interface.init(bootstrap.guest).await?; tracing::info!("Guest initialized successfully"); // Step 2: create the container (rootfs + image config + user mounts). This // does NOT run init — Container.Init only creates. tracing::info!("Sending container configuration to guest"); let mut container_interface = guest_session.container().await?; - let ca_certs: Vec = ca_cert_pem.into_iter().map(|s| s.to_string()).collect(); - let returned_id = container_interface - .init( - container_id_str, - container_image_config.clone(), - rootfs_init.clone(), - container_mounts.to_vec(), - ca_certs, - tty, - ) - .await?; + let returned_id = container_interface.init(bootstrap.container).await?; tracing::info!(container_id = %returned_id, "Container created"); // Running init is deliberately *not* done here. The container is created and diff --git a/src/boxlite/src/portal/interfaces/container.rs b/src/boxlite/src/portal/interfaces/container.rs index 042bf4bdf..47a1449fd 100644 --- a/src/boxlite/src/portal/interfaces/container.rs +++ b/src/boxlite/src/portal/interfaces/container.rs @@ -1,12 +1,16 @@ //! Container service interface. use boxlite_shared::{ - BindMount, BoxliteError, BoxliteResult, CaCert, ContainerClient, + BindMount, BoxliteError, BoxliteResult, CaCert, + ContainerAdvancedOptions as ProtoContainerAdvancedOptions, + ContainerCapabilities as ProtoContainerCapabilities, ContainerClient, ContainerConfig as ProtoContainerConfig, ContainerInitRequest, DiskRootfs, MergedRootfs, OverlayRootfs, RootfsInit, container_init_response, }; use tonic::transport::Channel; +use crate::images::ContainerImageConfig; +use crate::runtime::advanced_options::ContainerCapabilities; use crate::volumes::ContainerMount; /// Container rootfs initialization strategy. @@ -69,6 +73,25 @@ impl ContainerRootfsInitConfig { } } +/// Everything needed to create one container in the guest. +/// +/// Keeping this as a request object makes the host-to-guest boundary explicit +/// and prevents each new container option from widening [`ContainerInterface::init`]. +pub struct ContainerInitConfig { + pub container_id: String, + pub image: ContainerImageConfig, + pub rootfs: ContainerRootfsInitConfig, + pub mounts: Vec, + pub ca_certs: Vec, + pub tty: bool, + pub advanced: ContainerAdvancedConfig, +} + +/// Expert-only options crossing the host-to-guest container boundary. +pub struct ContainerAdvancedConfig { + pub capabilities: ContainerCapabilities, +} + /// Container service interface. pub struct ContainerInterface { client: ContainerClient, @@ -86,30 +109,31 @@ impl ContainerInterface { /// init; call [`Self::start`] for that. Creation and start are separate so /// the host can attach to the main command in between. Returns the /// container id on success. - /// - /// # Arguments - /// * `container_id` - Container ID (generated by host) - /// * `image_config` - Image-derived container config (entrypoint, env, workdir) - /// * `rootfs` - Rootfs initialization strategy - /// * `mounts` - Bind mounts from guest VM paths into container - /// * `tty` - give init a PTY rather than pipes (OCI `process.terminal`) - pub async fn init( - &mut self, - container_id: &str, - image_config: crate::images::ContainerImageConfig, - rootfs: ContainerRootfsInitConfig, - mounts: Vec, - ca_certs: Vec, - tty: bool, - ) -> BoxliteResult { + pub async fn init(&mut self, config: ContainerInitConfig) -> BoxliteResult { + let ContainerInitConfig { + container_id, + image, + rootfs, + mounts, + ca_certs, + tty, + advanced, + } = config; + let proto_config = ProtoContainerConfig { - entrypoint: image_config.final_cmd(), - env: image_config.env.clone(), - workdir: image_config.working_dir.clone(), - user: image_config.user.clone(), + entrypoint: image.final_cmd(), + env: image.env.clone(), + workdir: image.working_dir.clone(), + user: image.user.clone(), // Not an image property: `run -t` decides it, and init is the // process it applies to (OCI `process.terminal`). tty, + advanced: Some(ProtoContainerAdvancedOptions { + capabilities: Some(ProtoContainerCapabilities { + add: advanced.capabilities.add, + drop: advanced.capabilities.drop, + }), + }), }; // Convert ContainerMount to proto BindMount @@ -129,18 +153,19 @@ impl ContainerInterface { tracing::debug!(container_id = %container_id, "Sending ContainerInit request"); tracing::trace!( container_id = %container_id, - entrypoint = ?image_config.entrypoint, - cmd = ?image_config.cmd, - user = %image_config.user, - workdir = %image_config.working_dir, - env_count = image_config.env.len(), + entrypoint = ?image.entrypoint, + cmd = ?image.cmd, + user = %image.user, + workdir = %image.working_dir, + env_count = image.env.len(), + advanced = ?proto_config.advanced, rootfs = ?rootfs, mounts_count = proto_mounts.len(), "Container configuration" ); let request = ContainerInitRequest { - container_id: container_id.to_string(), + container_id: container_id.clone(), container_config: Some(proto_config), rootfs: Some(rootfs.into_proto()), mounts: proto_mounts, @@ -148,10 +173,15 @@ impl ContainerInterface { // Init's session id = container_id. The host declares it here so // `LiteBox::attach()` can address the main command with the same id // it sent, instead of both sides separately hard-coding it. - execution_id: container_id.to_string(), + execution_id: container_id.clone(), }; - let response = self.client.init(request).await?.into_inner(); + let response = self + .client + .init(request) + .await + .map_err(map_container_init_status)? + .into_inner(); match response.result { Some(container_init_response::Result::Success(success)) => { @@ -224,6 +254,13 @@ impl ContainerInterface { } } +fn map_container_init_status(status: tonic::Status) -> BoxliteError { + if status.code() == tonic::Code::InvalidArgument { + return BoxliteError::InvalidArgument(status.message().to_owned()); + } + status.into() +} + #[cfg(test)] mod tests { use super::*; @@ -235,6 +272,16 @@ mod tests { use tonic::transport::{Endpoint, Server}; use tonic::{Request, Response, Status}; + #[test] + fn container_init_preserves_invalid_argument_status() { + let error = map_container_init_status(Status::invalid_argument( + "unknown Linux capability 'CAP_FUTURE'", + )); + + assert!(matches!(error, BoxliteError::InvalidArgument(_))); + assert_eq!(error.http().0, 400); + } + /// How the stub guest answers `Container.Start`. #[derive(Clone, Copy)] enum StartReply { diff --git a/src/boxlite/src/portal/interfaces/guest.rs b/src/boxlite/src/portal/interfaces/guest.rs index 03e44673d..7aa204f02 100644 --- a/src/boxlite/src/portal/interfaces/guest.rs +++ b/src/boxlite/src/portal/interfaces/guest.rs @@ -68,6 +68,13 @@ impl GuestInterface { Ok(()) } + /// Fail before initialization if the connected guest cannot honor a + /// security-sensitive request field. Older guests return no features. + pub async fn require_feature(&mut self, feature: &str) -> BoxliteResult<()> { + let response = self.client.ping(PingRequest {}).await?.into_inner(); + ensure_guest_feature(&response.version, &response.features, feature) + } + /// Shutdown the guest agent. pub async fn shutdown(&mut self) -> BoxliteResult<()> { let _response = self.client.shutdown(ShutdownRequest {}).await?; @@ -93,6 +100,40 @@ impl GuestInterface { } } +fn ensure_guest_feature(version: &str, features: &[String], required: &str) -> BoxliteResult<()> { + if features.iter().any(|candidate| candidate == required) { + return Ok(()); + } + + Err(BoxliteError::Unsupported(format!( + "guest {version} does not support required feature '{required}'; recreate the box with the current runtime" + ))) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn old_guest_without_features_is_rejected_for_required_policy() { + let error = ensure_guest_feature("0.9.6", &[], "linux-capabilities-v2") + .expect_err("an old guest must not silently ignore security policy"); + + assert!(matches!(error, BoxliteError::Unsupported(_))); + assert!(error.to_string().contains("linux-capabilities-v2")); + } + + #[test] + fn advertised_guest_feature_is_accepted() { + ensure_guest_feature( + "0.9.7", + &["linux-capabilities-v2".to_string()], + "linux-capabilities-v2", + ) + .expect("current guest advertises the feature"); + } +} + /// Configuration for guest initialization. #[derive(Debug)] pub struct GuestInitConfig { diff --git a/src/boxlite/src/portal/interfaces/mod.rs b/src/boxlite/src/portal/interfaces/mod.rs index d4eba8bf5..6c952c010 100644 --- a/src/boxlite/src/portal/interfaces/mod.rs +++ b/src/boxlite/src/portal/interfaces/mod.rs @@ -7,7 +7,7 @@ pub mod exec; pub mod files; pub mod guest; -pub use container::{ContainerInterface, ContainerRootfsInitConfig}; +pub use container::{ContainerInitConfig, ContainerInterface, ContainerRootfsInitConfig}; pub use exec::ExecutionInterface; pub use files::FilesInterface; pub use guest::{GuestInitConfig, GuestInterface, NetworkInitConfig, VolumeConfig}; diff --git a/src/boxlite/src/rest/client.rs b/src/boxlite/src/rest/client.rs index d9effff4f..2266dbaa4 100644 --- a/src/boxlite/src/rest/client.rs +++ b/src/boxlite/src/rest/client.rs @@ -458,12 +458,16 @@ impl ApiClient { } } - let config: ServerConfig = self.get_root("/config").await?; + let config = self.fetch_config().await?; let mut cache = self.config_cache.write().await; *cache = Some(config.clone()); Ok(config) } + async fn fetch_config(&self) -> BoxliteResult { + self.get_root("/config").await + } + /// `GET /v1/me` — identity of the calling credential. Not cached /// (identity is per-credential and cheap; unlike static capabilities). /// A 404 surfaces as `BoxliteError::NotFound` (server without `/v1/me`); @@ -482,6 +486,22 @@ impl ApiClient { ensure_capability("snapshots", capabilities.snapshots_enabled) } + pub async fn require_linux_capabilities_enabled(&self) -> BoxliteResult<()> { + // This gate protects a security policy, so a cached positive response + // is insufficient after a server rollback or replacement. Recheck the + // live endpoint immediately before every capability-bearing create. + let config = self.fetch_config().await?; + let capabilities = config.capabilities.ok_or_else(|| { + BoxliteError::Unsupported( + "Remote server did not advertise Linux capabilities support".to_string(), + ) + })?; + ensure_capability( + "Linux capabilities", + capabilities.linux_capabilities_enabled, + ) + } + pub async fn require_clone_enabled(&self) -> BoxliteResult<()> { let config = self.get_config().await?; let capabilities = config.capabilities.ok_or_else(|| { @@ -732,6 +752,43 @@ mod tests { assert_eq!(client.current_bearer().await.unwrap(), None); } + #[tokio::test] + async fn linux_capability_gate_rechecks_uncached_server_config() { + let listener = TcpListener::bind(("127.0.0.1", 0)).await.unwrap(); + let port = listener.local_addr().unwrap().port(); + let server = tokio::spawn(async move { + for body in [ + r#"{"capabilities":{"linux_capabilities_enabled":true}}"#, + r#"{"capabilities":{}}"#, + ] { + let (mut socket, _) = listener.accept().await.unwrap(); + let mut headers = Vec::new(); + while !headers.ends_with(b"\r\n\r\n") { + headers.push(socket.read_u8().await.unwrap()); + } + socket + .write_all( + format!( + "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}", + body.len(), + body + ) + .as_bytes(), + ) + .await + .unwrap(); + } + }); + + let client = + ApiClient::new(&BoxliteRestOptions::new(format!("http://127.0.0.1:{port}"))).unwrap(); + client.require_linux_capabilities_enabled().await.unwrap(); + let second = client.require_linux_capabilities_enabled().await; + server.abort(); + + assert!(matches!(second, Err(BoxliteError::Unsupported(_)))); + } + #[test] fn flat_nest_error_response_maps_by_code() { let parsed: FlatErrorResponse = serde_json::from_str( diff --git a/src/boxlite/src/rest/runtime.rs b/src/boxlite/src/rest/runtime.rs index b1bd56909..adcc486dc 100644 --- a/src/boxlite/src/rest/runtime.rs +++ b/src/boxlite/src/rest/runtime.rs @@ -29,6 +29,51 @@ impl RestRuntime { let client = ApiClient::new(config)?; Ok(Self { client }) } + + async fn create_with_contract( + &self, + options: BoxOptions, + name: Option, + force_strict: bool, + ) -> BoxliteResult { + // Validate only the caller's requested policy. An unset auto_pause means + // "no auto-pause", so it must not borrow the server's default here. + crate::runtime::types::BoxLifecyclePolicy { + auto_pause: options.auto_pause.unwrap_or(0), + auto_delete: options.auto_delete.unwrap_or(0), + auto_resume: options.auto_resume.unwrap_or(true), + } + .validate()?; + + let has_capability_policy = !options.advanced.capabilities.is_empty(); + if has_capability_policy { + self.client.require_linux_capabilities_enabled().await?; + } + + let req = CreateBoxRequest::from_options(&options, name); + // The strict route was introduced with capability policy support. An + // older API instance returns 404 rather than accepting security-sensitive + // fields it does not understand. + let uses_strict_contract = force_strict || has_capability_policy; + let create_path = if uses_strict_contract { + "/boxes/strict" + } else { + "/boxes" + }; + let resp: BoxResponse = self.client.post(create_path, &req).await?; + if uses_strict_contract { + let Some(advanced) = &resp.advanced else { + return Err(BoxliteError::Unsupported( + "REST server did not return an authoritative Linux capability policy".into(), + )); + }; + let box_name = resp.name.as_deref().unwrap_or(&resp.box_id); + options.ensure_capability_policy_matches(&advanced.capability_policy(), box_name)?; + } + let info = resp.to_box_info()?; + let rest_box = Arc::new(RestBox::new(self.client.clone(), info)); + Ok(litebox_from_rest(rest_box)) + } } #[async_trait::async_trait] @@ -81,21 +126,7 @@ fn litebox_from_rest(rest_box: Arc) -> LiteBox { #[async_trait::async_trait] impl RuntimeBackend for RestRuntime { async fn create(&self, options: BoxOptions, name: Option) -> BoxliteResult { - // Validate only the caller's requested policy. An unset auto_pause means - // "no auto-pause", so it must not borrow the server's default here — - // otherwise a plain remove-on-stop box (`--rm` → auto_delete=1) is - // wrongly rejected by the ordering check before the request is even sent. - crate::runtime::types::BoxLifecyclePolicy { - auto_pause: options.auto_pause.unwrap_or(0), - auto_delete: options.auto_delete.unwrap_or(0), - auto_resume: options.auto_resume.unwrap_or(true), - } - .validate()?; - let req = CreateBoxRequest::from_options(&options, name); - let resp: BoxResponse = self.client.post("/boxes", &req).await?; - let info = resp.to_box_info()?; - let rest_box = Arc::new(RestBox::new(self.client.clone(), info)); - Ok(litebox_from_rest(rest_box)) + self.create_with_contract(options, name, false).await } async fn get_or_create( @@ -103,22 +134,44 @@ impl RuntimeBackend for RestRuntime { options: BoxOptions, name: Option, ) -> BoxliteResult<(LiteBox, bool)> { - // Try to get existing box by name first - if let Some(ref box_name) = name - && let Some(litebox) = self.get(box_name).await? - { - return Ok((litebox, false)); + if let Some(ref box_name) = name { + // Named reuse must be policy-aware even when the requested policy is + // the baseline. The versioned route prevents an old API instance + // from omitting the fields and masquerading as an empty policy. + let path = format!("/boxes/{box_name}/strict"); + match self.client.get::(&path).await { + Ok(resp) => { + let Some(advanced) = &resp.advanced else { + return Err(BoxliteError::Unsupported( + "REST server did not return an authoritative Linux capability policy" + .into(), + )); + }; + options.ensure_capability_policy_matches( + &advanced.capability_policy(), + box_name, + )?; + let info = resp.to_box_info()?; + let rest_box = Arc::new(RestBox::new(self.client.clone(), info)); + return Ok((litebox_from_rest(rest_box), false)); + } + Err(BoxliteError::NotFound(_)) => {} + Err(error) => return Err(error), + } + + let litebox = self.create_with_contract(options, name, true).await?; + return Ok((litebox, true)); } - // Create new box - let litebox = self.create(options, name).await?; + + let litebox = self.create_with_contract(options, name, false).await?; Ok((litebox, true)) } async fn get(&self, id_or_name: &str) -> BoxliteResult> { - let path = format!("/boxes/{}", id_or_name); + let path = format!("/boxes/{id_or_name}/strict"); match self.client.get::(&path).await { Ok(resp) => { - let info = resp.to_box_info()?; + let info = resp.to_authoritative_box_info()?; let rest_box = Arc::new(RestBox::new(self.client.clone(), info)); Ok(Some(litebox_from_rest(rest_box))) } @@ -128,17 +181,20 @@ impl RuntimeBackend for RestRuntime { } async fn get_info(&self, id_or_name: &str) -> BoxliteResult> { - let path = format!("/boxes/{}", id_or_name); + let path = format!("/boxes/{id_or_name}/strict"); match self.client.get::(&path).await { - Ok(resp) => Ok(Some(resp.to_box_info()?)), + Ok(resp) => Ok(Some(resp.to_authoritative_box_info()?)), Err(BoxliteError::NotFound(_)) => Ok(None), Err(e) => Err(e), } } async fn list_info(&self) -> BoxliteResult> { - let resp: ListBoxesResponse = self.client.get("/boxes").await?; - resp.boxes.iter().map(|b| b.to_box_info()).collect() + let resp: ListBoxesResponse = self.client.get("/boxes/strict").await?; + resp.boxes + .iter() + .map(BoxResponse::to_authoritative_box_info) + .collect() } async fn exists(&self, id_or_name: &str) -> BoxliteResult { @@ -227,6 +283,8 @@ fn runtime_metrics_from_response(resp: &RuntimeMetricsResponse) -> RuntimeMetric #[cfg(test)] mod tests { use super::*; + use tokio::io::{AsyncReadExt, AsyncWriteExt}; + use tokio::net::TcpListener; #[tokio::test] async fn test_import_box_requires_capability() { @@ -271,4 +329,258 @@ mod tests { "remove-on-stop without auto_pause must pass client validation; got: {err}" ); } + + #[tokio::test] + async fn custom_capabilities_require_server_advertisement_before_create() { + let listener = TcpListener::bind(("127.0.0.1", 0)).await.unwrap(); + let port = listener.local_addr().unwrap().port(); + let server = tokio::spawn(async move { + let (mut socket, _) = listener.accept().await.unwrap(); + let mut headers = Vec::new(); + while !headers.ends_with(b"\r\n\r\n") { + headers.push(socket.read_u8().await.unwrap()); + } + let request = String::from_utf8(headers).unwrap(); + let body = r#"{"capabilities":{}}"#; + socket + .write_all( + format!( + "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}", + body.len(), + body + ) + .as_bytes(), + ) + .await + .unwrap(); + request.lines().next().unwrap().to_string() + }); + + let runtime = + RestRuntime::new(&BoxliteRestOptions::new(format!("http://127.0.0.1:{port}"))).unwrap(); + let result = RuntimeBackend::create( + &runtime, + BoxOptions { + advanced: crate::AdvancedBoxOptions { + capabilities: crate::ContainerCapabilities { + drop: vec!["NET_RAW".into()], + ..Default::default() + }, + ..Default::default() + }, + ..Default::default() + }, + None, + ) + .await; + let error = match result { + Err(error) => error, + Ok(_) => panic!("an old server must not silently ignore a capability policy"), + }; + + assert!(matches!(error, BoxliteError::Unsupported(_))); + assert_eq!(server.await.unwrap(), "GET /v1/config HTTP/1.1"); + } + + #[tokio::test] + async fn custom_capabilities_use_strict_create_route() { + let listener = TcpListener::bind(("127.0.0.1", 0)).await.unwrap(); + let port = listener.local_addr().unwrap().port(); + let server = tokio::spawn(async move { + let mut requests = Vec::new(); + for body in [ + r#"{"capabilities":{"linux_capabilities_enabled":true}}"#, + r#"{"box_id":"01HJK4TNRPQSXYZ8WM6NCVT9R5","name":null,"status":"configured","created_at":"2026-01-01T00:00:00Z","updated_at":"2026-01-01T00:00:00Z","pid":null,"image":"alpine:latest","cpus":2,"memory_mib":512,"advanced":{"capabilities":{"add":["SYS_ADMIN"],"drop":[]}},"labels":{}}"#, + ] { + let (mut socket, _) = listener.accept().await.unwrap(); + let mut headers = Vec::new(); + while !headers.ends_with(b"\r\n\r\n") { + headers.push(socket.read_u8().await.unwrap()); + } + let request = String::from_utf8(headers).unwrap(); + requests.push(request.lines().next().unwrap().to_string()); + socket + .write_all( + format!( + "HTTP/1.1 201 Created\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}", + body.len(), + body + ) + .as_bytes(), + ) + .await + .unwrap(); + } + requests + }); + + let runtime = + RestRuntime::new(&BoxliteRestOptions::new(format!("http://127.0.0.1:{port}"))).unwrap(); + RuntimeBackend::create( + &runtime, + BoxOptions { + advanced: crate::AdvancedBoxOptions { + capabilities: crate::ContainerCapabilities { + add: vec!["SYS_ADMIN".into()], + ..Default::default() + }, + ..Default::default() + }, + ..Default::default() + }, + None, + ) + .await + .unwrap(); + + assert_eq!( + server.await.unwrap(), + ["GET /v1/config HTTP/1.1", "POST /v1/boxes/strict HTTP/1.1"] + ); + } + + #[tokio::test] + async fn strict_create_rejects_response_without_capability_policy() { + let listener = TcpListener::bind(("127.0.0.1", 0)).await.unwrap(); + let port = listener.local_addr().unwrap().port(); + let server = tokio::spawn(async move { + for body in [ + r#"{"capabilities":{"linux_capabilities_enabled":true}}"#, + r#"{"box_id":"01HJK4TNRPQSXYZ8WM6NCVT9R5","name":null,"status":"configured","created_at":"2026-01-01T00:00:00Z","updated_at":"2026-01-01T00:00:00Z","pid":null,"image":"alpine:latest","cpus":2,"memory_mib":512,"labels":{}}"#, + ] { + let (mut socket, _) = listener.accept().await.unwrap(); + let mut headers = Vec::new(); + while !headers.ends_with(b"\r\n\r\n") { + headers.push(socket.read_u8().await.unwrap()); + } + socket + .write_all( + format!( + "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}", + body.len(), + body + ) + .as_bytes(), + ) + .await + .unwrap(); + } + }); + + let runtime = + RestRuntime::new(&BoxliteRestOptions::new(format!("http://127.0.0.1:{port}"))).unwrap(); + let result = RuntimeBackend::create( + &runtime, + BoxOptions { + advanced: crate::AdvancedBoxOptions { + capabilities: crate::ContainerCapabilities { + drop: vec!["NET_RAW".into()], + ..Default::default() + }, + ..Default::default() + }, + ..Default::default() + }, + None, + ) + .await; + + assert!( + matches!(result, Err(BoxliteError::Unsupported(_))), + "a strict response without an authoritative policy must fail closed" + ); + server.await.unwrap(); + } + + #[tokio::test] + async fn get_or_create_rejects_response_without_capability_policy() { + let listener = TcpListener::bind(("127.0.0.1", 0)).await.unwrap(); + let port = listener.local_addr().unwrap().port(); + let server = tokio::spawn(async move { + let (mut socket, _) = listener.accept().await.unwrap(); + let mut headers = Vec::new(); + while !headers.ends_with(b"\r\n\r\n") { + headers.push(socket.read_u8().await.unwrap()); + } + let request = String::from_utf8(headers).unwrap(); + let body = r#"{"box_id":"01HJK4TNRPQSXYZ8WM6NCVT9R5","name":"named","status":"configured","created_at":"2026-01-01T00:00:00Z","updated_at":"2026-01-01T00:00:00Z","pid":null,"image":"alpine:latest","cpus":2,"memory_mib":512,"labels":{}}"#; + socket + .write_all( + format!( + "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}", + body.len(), + body + ) + .as_bytes(), + ) + .await + .unwrap(); + request.lines().next().unwrap().to_string() + }); + + let runtime = + RestRuntime::new(&BoxliteRestOptions::new(format!("http://127.0.0.1:{port}"))).unwrap(); + let result = + RuntimeBackend::get_or_create(&runtime, BoxOptions::default(), Some("named".into())) + .await; + let error = match result { + Err(error) => error, + Ok(_) => panic!("an omitted policy must not masquerade as the baseline policy"), + }; + + assert!(matches!(error, BoxliteError::Unsupported(_))); + assert_eq!(server.await.unwrap(), "GET /v1/boxes/named/strict HTTP/1.1"); + } + + #[tokio::test] + async fn authoritative_inspection_rejects_responses_without_capability_policy() { + let listener = TcpListener::bind(("127.0.0.1", 0)).await.unwrap(); + let port = listener.local_addr().unwrap().port(); + let server = tokio::spawn(async move { + let box_json = r#"{"box_id":"01HJK4TNRPQSXYZ8WM6NCVT9R5","name":"named","status":"configured","created_at":"2026-01-01T00:00:00Z","updated_at":"2026-01-01T00:00:00Z","pid":null,"image":"alpine:latest","cpus":2,"memory_mib":512,"labels":{}}"#; + let list_json = format!(r#"{{"boxes":[{box_json}],"next_page_token":null}}"#); + let bodies = [box_json.to_string(), box_json.to_string(), list_json]; + let mut requests = Vec::new(); + + for body in bodies { + let (mut socket, _) = listener.accept().await.unwrap(); + let mut headers = Vec::new(); + while !headers.ends_with(b"\r\n\r\n") { + headers.push(socket.read_u8().await.unwrap()); + } + let request = String::from_utf8(headers).unwrap(); + requests.push(request.lines().next().unwrap().to_string()); + socket + .write_all( + format!( + "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}", + body.len(), + body + ) + .as_bytes(), + ) + .await + .unwrap(); + } + requests + }); + + let runtime = + RestRuntime::new(&BoxliteRestOptions::new(format!("http://127.0.0.1:{port}"))).unwrap(); + let get_result = RuntimeBackend::get(&runtime, "named").await; + let get_info_result = RuntimeBackend::get_info(&runtime, "named").await; + let list_result = RuntimeBackend::list_info(&runtime).await; + + assert!(matches!(get_result, Err(BoxliteError::Unsupported(_)))); + assert!(matches!(get_info_result, Err(BoxliteError::Unsupported(_)))); + assert!(matches!(list_result, Err(BoxliteError::Unsupported(_)))); + assert_eq!( + server.await.unwrap(), + [ + "GET /v1/boxes/named/strict HTTP/1.1", + "GET /v1/boxes/named/strict HTTP/1.1", + "GET /v1/boxes/strict HTTP/1.1", + ] + ); + } } diff --git a/src/boxlite/src/rest/types.rs b/src/boxlite/src/rest/types.rs index 4ae496828..5b2c48354 100644 --- a/src/boxlite/src/rest/types.rs +++ b/src/boxlite/src/rest/types.rs @@ -5,10 +5,12 @@ use std::collections::HashMap; +use boxlite_shared::errors::BoxliteError; use serde::{Deserialize, Serialize}; use crate::litebox::BoxStatus; use crate::litebox::snapshot_mgr::SnapshotInfo; +use crate::runtime::advanced_options::ContainerCapabilities; use crate::runtime::options::{CloneOptions, ExportOptions, SnapshotOptions}; // ============================================================================ @@ -75,6 +77,7 @@ pub(crate) struct ServerConfig { #[allow(dead_code)] // Constructed via serde::Deserialize #[derive(Debug, Deserialize, Clone, Default)] pub(crate) struct ServerCapabilities { + pub linux_capabilities_enabled: Option, pub snapshots_enabled: Option, pub clone_enabled: Option, pub export_enabled: Option, @@ -122,6 +125,8 @@ pub(crate) struct CreateBoxRequest { #[serde(skip_serializing_if = "Option::is_none")] pub tty: Option, #[serde(skip_serializing_if = "Option::is_none")] + pub advanced: Option, + #[serde(skip_serializing_if = "Option::is_none")] pub auto_pause: Option, #[serde(skip_serializing_if = "Option::is_none")] pub auto_delete: Option, @@ -177,6 +182,11 @@ impl CreateBoxRequest { secrets, detach: Some(options.detach), tty: options.tty.then_some(true), + advanced: (!options.advanced.capabilities.is_empty()).then(|| { + CreateBoxAdvancedOptions { + capabilities: options.advanced.capabilities.clone(), + } + }), // The deprecated remove-on-stop flag was never applied by the cloud // control-plane mapper. Keep remote defaults unchanged and only send // the modern lifecycle fields when callers explicitly configure them. @@ -187,6 +197,11 @@ impl CreateBoxRequest { } } +#[derive(Debug, Serialize)] +pub(crate) struct CreateBoxAdvancedOptions { + pub capabilities: ContainerCapabilities, +} + #[derive(Debug, Serialize)] pub(crate) struct CreateBoxNetworkSpec { pub mode: String, @@ -239,6 +254,9 @@ pub(crate) struct BoxResponse { pub image: String, pub cpus: u8, pub memory_mib: u32, + /// `None` means an older server omitted expert inspection metadata. + #[serde(default)] + pub advanced: Option, #[serde(default)] pub labels: HashMap, /// Absent while the box's main command is still running. An older server @@ -255,9 +273,20 @@ pub(crate) struct BoxResponse { } impl BoxResponse { + pub(crate) fn to_authoritative_box_info( + &self, + ) -> boxlite_shared::errors::BoxliteResult { + if self.advanced.is_none() { + let box_name = self.name.as_deref().unwrap_or(&self.box_id); + return Err(BoxliteError::Unsupported(format!( + "REST server did not return an authoritative Linux capability policy for box {box_name}" + ))); + } + self.to_box_info() + } + pub fn to_box_info(&self) -> boxlite_shared::errors::BoxliteResult { use crate::runtime::id::BoxID; - use boxlite_shared::errors::BoxliteError; let id = BoxID::parse(&self.box_id).ok_or_else(|| { BoxliteError::Internal(format!( @@ -287,6 +316,11 @@ impl BoxResponse { image: self.image.clone(), cpus: self.cpus, memory_mib: self.memory_mib, + advanced: self + .advanced + .as_ref() + .map(BoxAdvancedResponse::to_core) + .unwrap_or_default(), labels: self.labels.clone(), auto_pause: self.auto_pause, auto_delete: self.auto_delete, @@ -297,6 +331,32 @@ impl BoxResponse { } } +#[derive(Debug, Deserialize)] +pub(crate) struct BoxAdvancedResponse { + pub capabilities: AuthoritativeContainerCapabilities, +} + +#[derive(Debug, Deserialize)] +pub(crate) struct AuthoritativeContainerCapabilities { + pub add: Vec, + pub drop: Vec, +} + +impl BoxAdvancedResponse { + pub(crate) fn capability_policy(&self) -> ContainerCapabilities { + ContainerCapabilities { + add: self.capabilities.add.clone(), + drop: self.capabilities.drop.clone(), + } + } + + fn to_core(&self) -> crate::runtime::types::BoxAdvancedInfo { + crate::runtime::types::BoxAdvancedInfo { + capabilities: self.capability_policy(), + } + } +} + fn default_auto_pause() -> u32 { 900 } @@ -584,6 +644,7 @@ mod tests { placeholder: "".into(), }]), detach: None, + advanced: None, auto_pause: Some(900), auto_delete: Some(604800), auto_resume: None, @@ -645,6 +706,35 @@ mod tests { ); } + #[test] + fn test_create_box_request_carries_container_capabilities() { + let opts = BoxOptions { + advanced: crate::AdvancedBoxOptions { + capabilities: ContainerCapabilities { + add: vec!["SYS_ADMIN".into()], + drop: vec!["CAP_NET_RAW".into()], + }, + ..Default::default() + }, + ..Default::default() + }; + + let req = CreateBoxRequest::from_options(&opts, None); + let advanced = req.advanced.as_ref().expect("custom policy is serialized"); + assert_eq!(advanced.capabilities.add, ["SYS_ADMIN"]); + assert_eq!(advanced.capabilities.drop, ["CAP_NET_RAW"]); + + let json = serde_json::to_value(&req).expect("serialize create request"); + assert_eq!( + json["advanced"]["capabilities"], + serde_json::json!({"add": ["SYS_ADMIN"], "drop": ["CAP_NET_RAW"]}) + ); + + let defaults = CreateBoxRequest::from_options(&BoxOptions::default(), None); + let defaults_json = serde_json::to_value(defaults).expect("serialize defaults"); + assert!(defaults_json.get("advanced").is_none()); + } + #[test] #[allow(deprecated)] fn deprecated_auto_remove_does_not_change_rest_lifecycle_defaults() { @@ -755,6 +845,12 @@ mod tests { image: "python:3.11".to_string(), cpus: 2, memory_mib: 512, + advanced: Some(BoxAdvancedResponse { + capabilities: AuthoritativeContainerCapabilities { + add: vec!["SYS_ADMIN".into()], + drop: vec!["NET_RAW".into()], + }, + }), labels: HashMap::new(), exit_code: None, auto_pause: 1800, @@ -766,6 +862,8 @@ mod tests { assert_eq!(info.image, "python:3.11"); assert_eq!(info.cpus, 2); assert_eq!(info.memory_mib, 512); + assert_eq!(info.advanced.capabilities.add, ["SYS_ADMIN"]); + assert_eq!(info.advanced.capabilities.drop, ["NET_RAW"]); assert_eq!(info.auto_pause, 1800); assert_eq!(info.auto_delete, 604800); } @@ -784,6 +882,12 @@ mod tests { image: "alpine:latest".to_string(), cpus: 1, memory_mib: 256, + advanced: Some(BoxAdvancedResponse { + capabilities: AuthoritativeContainerCapabilities { + add: Vec::new(), + drop: Vec::new(), + }, + }), labels: HashMap::new(), exit_code: None, auto_pause: 900, @@ -812,6 +916,12 @@ mod tests { image: "alpine:latest".to_string(), cpus: 1, memory_mib: 256, + advanced: Some(BoxAdvancedResponse { + capabilities: AuthoritativeContainerCapabilities { + add: Vec::new(), + drop: Vec::new(), + }, + }), labels: HashMap::new(), exit_code: None, auto_pause: 900, @@ -884,6 +994,12 @@ mod tests { image: "python:3.11".to_string(), cpus: 2, memory_mib: 512, + advanced: Some(BoxAdvancedResponse { + capabilities: AuthoritativeContainerCapabilities { + add: Vec::new(), + drop: Vec::new(), + }, + }), labels: HashMap::new(), exit_code: None, auto_pause: 900, @@ -902,12 +1018,14 @@ mod tests { let json = r#"{ "capabilities": { "snapshots_enabled": true, + "linux_capabilities_enabled": true, "clone_enabled": false, "export_enabled": true } }"#; let resp: ServerConfig = serde_json::from_str(json).unwrap(); let caps = resp.capabilities.unwrap(); + assert_eq!(caps.linux_capabilities_enabled, Some(true)); assert_eq!(caps.snapshots_enabled, Some(true)); assert_eq!(caps.clone_enabled, Some(false)); assert_eq!(caps.export_enabled, Some(true)); diff --git a/src/boxlite/src/runtime/advanced_options.rs b/src/boxlite/src/runtime/advanced_options.rs index 0d2ca8132..4c03086c8 100644 --- a/src/boxlite/src/runtime/advanced_options.rs +++ b/src/boxlite/src/runtime/advanced_options.rs @@ -1,8 +1,9 @@ //! Advanced options for expert users. //! -//! This module contains [`AdvancedBoxOptions`], [`SecurityOptions`], [`ResourceLimits`], -//! and [`SecurityOptionsBuilder`] — configuration that entry-level users can safely -//! ignore. Defaults prioritize compatibility. +//! This module contains [`AdvancedBoxOptions`], [`ContainerCapabilities`], +//! [`SecurityOptions`], [`ResourceLimits`], and [`SecurityOptionsBuilder`] — +//! configuration that entry-level users can safely ignore. Defaults prioritize +//! compatibility. use serde::{Deserialize, Serialize}; use std::path::PathBuf; @@ -561,12 +562,112 @@ impl SecurityOptionsBuilder { // Advanced Options // ============================================================================ +/// Linux capability policy for the container process. +/// +/// Capability names are case-insensitive and may include the `CAP_` prefix. +/// The special value `ALL` is accepted in either list. For named conflicts an +/// explicit addition wins; with `add = ["ALL"]`, named removals win. With +/// `drop = ["ALL"]`, explicit additions form the complete resulting set. +#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(default, deny_unknown_fields)] +pub struct ContainerCapabilities { + /// Capabilities to add to BoxLite's Docker-compatible baseline. + pub add: Vec, + + /// Capabilities to remove from the resulting capability set. + pub drop: Vec, +} + +impl ContainerCapabilities { + /// Whether this policy leaves the default capability set unchanged. + pub fn is_empty(&self) -> bool { + self.add.is_empty() && self.drop.is_empty() + } + + pub(crate) fn validate(&self) -> boxlite_shared::errors::BoxliteResult<()> { + validate_capability_names("advanced.capabilities.add", &self.add)?; + validate_capability_names("advanced.capabilities.drop", &self.drop) + } + + /// Validate the policy before `get_or_create` adopts an existing box. + pub(crate) fn ensure_matches( + &self, + existing: &Self, + box_name: &str, + ) -> boxlite_shared::errors::BoxliteResult<()> { + let canonicalize = |capabilities: &[String]| { + capabilities + .iter() + .map(|capability| { + let normalized = capability.to_ascii_uppercase(); + if normalized == "ALL" { + normalized + } else { + normalized + .strip_prefix("CAP_") + .unwrap_or(&normalized) + .to_string() + } + }) + .collect::>() + }; + + if canonicalize(&self.add) == canonicalize(&existing.add) + && canonicalize(&self.drop) == canonicalize(&existing.drop) + { + return Ok(()); + } + + Err(boxlite_shared::errors::BoxliteError::InvalidArgument( + format!( + "capability policy does not match existing box '{box_name}'; \ + get_or_create never changes an existing box's security policy" + ), + )) + } +} + +fn validate_capability_names( + option: &str, + capabilities: &[String], +) -> boxlite_shared::errors::BoxliteResult<()> { + for capability in capabilities { + let normalized = capability.to_ascii_uppercase(); + if normalized == "ALL" { + continue; + } + + let name = normalized.strip_prefix("CAP_").unwrap_or(&normalized); + if name.is_empty() { + return Err(boxlite_shared::errors::BoxliteError::InvalidArgument( + format!("empty Linux capability in {option}"), + )); + } + let mut bytes = name.bytes(); + let starts_with_letter = bytes.next().is_some_and(|byte| byte.is_ascii_uppercase()); + let has_valid_tail = + bytes.all(|byte| byte.is_ascii_uppercase() || byte.is_ascii_digit() || byte == b'_'); + if !starts_with_letter || !has_valid_tail { + return Err(boxlite_shared::errors::BoxliteError::InvalidArgument( + format!("malformed Linux capability in {option}: {capability}"), + )); + } + } + + Ok(()) +} + /// Advanced options for expert users. /// /// Entry-level users can ignore this — the defaults are secure and sensible. /// Only modify these if you understand the security implications. #[derive(Clone, Debug, Default, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] pub struct AdvancedBoxOptions { + /// Linux capability policy for the container process. + #[serde(default)] + pub capabilities: ContainerCapabilities, + /// Security isolation options (jailer, seccomp, namespaces, resource limits). /// /// Secure by default: the default is the fully-enabled profile diff --git a/src/boxlite/src/runtime/core.rs b/src/boxlite/src/runtime/core.rs index 2e653f7f7..755915704 100644 --- a/src/boxlite/src/runtime/core.rs +++ b/src/boxlite/src/runtime/core.rs @@ -288,7 +288,8 @@ impl BoxliteRuntime { /// /// Returns `(LiteBox, true)` if a new box was created, or `(LiteBox, false)` /// if an existing box with the given name was found. When an existing box is - /// returned, the provided `options` are ignored (no config drift validation). + /// returned, general options are ignored, but its capability policy must + /// match exactly so reuse cannot silently weaken or elevate privileges. pub async fn get_or_create( &self, options: BoxOptions, diff --git a/src/boxlite/src/runtime/import.rs b/src/boxlite/src/runtime/import.rs index 3c0a3da21..22e1c8f18 100644 --- a/src/boxlite/src/runtime/import.rs +++ b/src/boxlite/src/runtime/import.rs @@ -8,8 +8,8 @@ use boxlite_shared::errors::{BoxliteError, BoxliteResult}; use crate::disk::constants::filenames as disk_filenames; use crate::litebox::LiteBox; use crate::litebox::archive::{ - ArchiveManifest, MANIFEST_FILENAME, MAX_SUPPORTED_VERSION, extract_archive, move_file, - sha256_file, + ArchiveManifest, CAPABILITY_POLICY_ARCHIVE_VERSION, MANIFEST_FILENAME, MAX_SUPPORTED_VERSION, + extract_archive, move_file, sha256_file, }; use crate::runtime::options::{BoxArchive, BoxOptions, RootfsSpec}; use crate::runtime::rt_impl::RuntimeImpl; @@ -42,6 +42,8 @@ pub(crate) async fn import_box( BoxliteError::Internal(format!("Import extraction task panicked: {}", e)) })??; + let options = options_from_manifest(&manifest)?; + // Phase 2: Validate disks and install into a staging directory (blocking I/O). // The staging dir lives inside temp_dir; provision_box will rename it. let staging_dir = temp_dir.path().join("staging"); @@ -51,12 +53,6 @@ pub(crate) async fn import_box( .await .map_err(|e| BoxliteError::Internal(format!("Import install task panicked: {}", e)))??; - // Use full BoxOptions from v3+ manifest, or reconstruct from image for v1/v2. - let options = manifest.box_options.unwrap_or_else(|| BoxOptions { - rootfs: RootfsSpec::Image(manifest.image), - ..Default::default() - }); - let litebox = runtime .provision_box(staging_dir, name, options, BoxStatus::Stopped) .await?; @@ -70,6 +66,26 @@ pub(crate) async fn import_box( Ok(litebox) } +/// Read the persisted configuration, falling back to the v1/v2 image field. +fn options_from_manifest(manifest: &ArchiveManifest) -> BoxliteResult { + let options = manifest.box_options.clone().unwrap_or_else(|| BoxOptions { + rootfs: RootfsSpec::Image(manifest.image.clone()), + ..Default::default() + }); + if manifest.version < CAPABILITY_POLICY_ARCHIVE_VERSION + && !options.advanced.capabilities.is_empty() + { + return Err(BoxliteError::InvalidArgument(format!( + "archive capability policy requires manifest version {} or newer", + CAPABILITY_POLICY_ARCHIVE_VERSION + ))); + } + options.sanitize().map_err(|error| { + BoxliteError::InvalidArgument(format!("invalid archive box_options: {error}")) + })?; + Ok(options) +} + /// Extract archive, parse manifest, verify checksums. fn extract_and_validate( archive_path: &Path, @@ -186,6 +202,60 @@ mod tests { use super::*; use tempfile::TempDir; + #[test] + fn imported_capability_policy_is_validated_before_install() { + let manifest = ArchiveManifest { + version: 4, + box_name: Some("untrusted".into()), + image: "alpine:latest".into(), + box_options: Some(BoxOptions { + advanced: crate::runtime::advanced_options::AdvancedBoxOptions { + capabilities: crate::runtime::advanced_options::ContainerCapabilities { + drop: vec!["NET-ADMIN".into()], + ..Default::default() + }, + ..Default::default() + }, + ..Default::default() + }), + guest_disk_checksum: String::new(), + container_disk_checksum: String::new(), + exported_at: "2026-01-01T00:00:00Z".into(), + }; + + let error = options_from_manifest(&manifest) + .expect_err("malformed archived capability policy must be rejected"); + assert!(matches!(error, BoxliteError::InvalidArgument(_))); + assert!(error.to_string().contains("NET-ADMIN")); + } + + #[test] + fn archive_v3_cannot_smuggle_a_capability_policy() { + let manifest = ArchiveManifest { + version: 3, + box_name: Some("mislabeled".into()), + image: "alpine:latest".into(), + box_options: Some(BoxOptions { + advanced: crate::runtime::advanced_options::AdvancedBoxOptions { + capabilities: crate::runtime::advanced_options::ContainerCapabilities { + drop: vec!["ALL".into()], + ..Default::default() + }, + ..Default::default() + }, + ..Default::default() + }), + guest_disk_checksum: String::new(), + container_disk_checksum: String::new(), + exported_at: "2026-01-01T00:00:00Z".into(), + }; + + let error = options_from_manifest(&manifest) + .expect_err("v3 archives must not carry v4 capability policy fields"); + assert!(matches!(error, BoxliteError::InvalidArgument(_))); + assert!(error.to_string().contains("version 4")); + } + #[test] fn test_validate_no_backing_references_rejects_absolute() { let dir = TempDir::new_in("/tmp").unwrap(); diff --git a/src/boxlite/src/runtime/options.rs b/src/boxlite/src/runtime/options.rs index 0e8c6b857..7765befc8 100644 --- a/src/boxlite/src/runtime/options.rs +++ b/src/boxlite/src/runtime/options.rs @@ -312,7 +312,7 @@ mod registry_options_tests { /// Options used when constructing a box. #[derive(Clone, Debug, serde::Serialize, serde::Deserialize)] -#[serde(default)] +#[serde(default, deny_unknown_fields)] pub struct BoxOptions { pub cpus: Option, pub memory_mib: Option, @@ -371,7 +371,7 @@ pub struct BoxOptions { #[serde(default = "default_detach")] pub detach: bool, - /// Advanced options for expert users (security, mount isolation). + /// Advanced options for expert users (capabilities, security, mount isolation). /// /// Defaults are secure — most users can ignore this entirely. /// See [`AdvancedBoxOptions`] for details. @@ -556,6 +556,7 @@ impl BoxOptions { /// - effective remove-on-stop (`auto_delete>0`, or deprecated `auto_remove`) /// with `detach=true` is invalid /// - `advanced.isolate_mounts=true` is only supported on Linux + /// - `advanced.capabilities` contains well-formed Linux capability names pub fn sanitize(&self) -> BoxliteResult<()> { if self.removes_on_stop() && self.detach { return Err(boxlite_shared::errors::BoxliteError::Config( @@ -571,8 +572,22 @@ impl BoxOptions { "isolate_mounts is only supported on Linux".to_string(), )); } + + self.advanced.capabilities.validate()?; + Ok(()) } + + /// Validate the security policy before `get_or_create` adopts an existing box. + pub(crate) fn ensure_capability_policy_matches( + &self, + existing: &crate::runtime::advanced_options::ContainerCapabilities, + box_name: &str, + ) -> BoxliteResult<()> { + self.advanced + .capabilities + .ensure_matches(existing, box_name) + } } /// How to populate the box root filesystem. @@ -757,7 +772,9 @@ pub struct CloneOptions {} #[cfg(test)] mod tests { use super::*; - use crate::runtime::advanced_options::{SecurityOptions, SecurityOptionsBuilder}; + use crate::runtime::advanced_options::{ + ContainerCapabilities, SecurityOptions, SecurityOptionsBuilder, + }; #[test] #[allow(deprecated)] @@ -769,6 +786,218 @@ mod tests { "auto_remove should keep its legacy default" ); assert!(!opts.detach, "detach should default to false"); + assert!( + opts.advanced.capabilities.is_empty(), + "advanced capabilities should default to empty" + ); + } + + #[test] + fn box_options_capabilities_serde_roundtrip() { + let json = r#"{ + "advanced": { + "capabilities": { + "add": ["SYS_ADMIN", "CAP_NET_ADMIN"], + "drop": ["NET_RAW"] + } + } + }"#; + + let opts: BoxOptions = serde_json::from_str(json).unwrap(); + assert_eq!( + opts.advanced.capabilities.add, + ["SYS_ADMIN", "CAP_NET_ADMIN"] + ); + assert_eq!(opts.advanced.capabilities.drop, ["NET_RAW"]); + + let serialized = serde_json::to_string(&opts).unwrap(); + let roundtripped: BoxOptions = serde_json::from_str(&serialized).unwrap(); + assert_eq!( + roundtripped.advanced.capabilities, + opts.advanced.capabilities + ); + } + + #[test] + fn box_options_serde_rejects_unknown_flat_capability_fields() { + let error = serde_json::from_str::(r#"{"cap_drop":["NET_RAW"]}"#) + .expect_err("flat capability fields must not be silently ignored"); + + assert!(error.to_string().contains("cap_drop")); + } + + #[test] + fn advanced_options_serde_rejects_misspelled_capabilities_field() { + let error = serde_json::from_str::( + r#"{"advanced":{"capabilites":{"drop":["NET_RAW"]}}}"#, + ) + .expect_err("misspelled advanced capability fields must not be silently ignored"); + + assert!(error.to_string().contains("capabilites")); + } + + #[test] + fn container_capabilities_serde_rejects_misspelled_drop_field() { + let error = serde_json::from_str::( + r#"{"advanced":{"capabilities":{"dorp":["NET_RAW"]}}}"#, + ) + .expect_err("misspelled capability policy fields must not be silently ignored"); + + assert!(error.to_string().contains("dorp")); + } + + #[test] + fn box_options_sanitize_accepts_valid_capability_names() { + let opts = BoxOptions { + advanced: AdvancedBoxOptions { + capabilities: ContainerCapabilities { + add: vec!["sys_admin".into(), "CAP_NET_ADMIN".into()], + drop: vec!["NET_RAW".into()], + }, + ..Default::default() + }, + ..Default::default() + }; + + opts.sanitize() + .expect("Docker-style capability names should be accepted"); + } + + #[test] + fn box_options_sanitize_accepts_future_capability_names() { + let opts = BoxOptions { + advanced: AdvancedBoxOptions { + capabilities: ContainerCapabilities { + add: vec!["FUTURE_KERNEL_FEATURE".into()], + ..Default::default() + }, + ..Default::default() + }, + ..Default::default() + }; + + opts.sanitize() + .expect("the guest runtime, not the host SDK, owns the supported capability list"); + } + + #[test] + fn box_options_sanitize_rejects_malformed_capability_names() { + for opts in [ + BoxOptions { + advanced: AdvancedBoxOptions { + capabilities: ContainerCapabilities { + add: vec!["".into()], + ..Default::default() + }, + ..Default::default() + }, + ..Default::default() + }, + BoxOptions { + advanced: AdvancedBoxOptions { + capabilities: ContainerCapabilities { + drop: vec!["NET-ADMIN".into()], + ..Default::default() + }, + ..Default::default() + }, + ..Default::default() + }, + BoxOptions { + advanced: AdvancedBoxOptions { + capabilities: ContainerCapabilities { + add: vec!["123".into()], + ..Default::default() + }, + ..Default::default() + }, + ..Default::default() + }, + BoxOptions { + advanced: AdvancedBoxOptions { + capabilities: ContainerCapabilities { + add: vec!["ß".into()], + ..Default::default() + }, + ..Default::default() + }, + ..Default::default() + }, + ] { + let err = opts + .sanitize() + .expect_err("malformed capability should be rejected"); + assert_eq!(err.http().0, 400); + let err = err.to_string(); + assert!( + err.contains("empty") + || err.contains("NET-ADMIN") + || err.contains("123") + || err.contains("ß"), + "error should identify the malformed capability, got: {err}" + ); + } + } + + #[test] + fn get_or_create_rejects_capability_policy_drift() { + let baseline = BoxOptions::default(); + assert!( + baseline + .ensure_capability_policy_matches(&ContainerCapabilities::default(), "same-policy") + .is_ok() + ); + + let spelling_variant = BoxOptions { + advanced: AdvancedBoxOptions { + capabilities: ContainerCapabilities { + add: vec!["sys_admin".into(), "CAP_NET_ADMIN".into()], + ..Default::default() + }, + ..Default::default() + }, + ..Default::default() + }; + assert!( + spelling_variant + .ensure_capability_policy_matches( + &ContainerCapabilities { + add: vec!["NET_ADMIN".into(), "CAP_SYS_ADMIN".into()], + ..Default::default() + }, + "same-policy" + ) + .is_ok() + ); + + let restricted = BoxOptions { + advanced: AdvancedBoxOptions { + capabilities: ContainerCapabilities { + drop: vec!["ALL".into()], + ..Default::default() + }, + ..Default::default() + }, + ..Default::default() + }; + assert!(matches!( + restricted.ensure_capability_policy_matches( + &ContainerCapabilities::default(), + "baseline-box" + ), + Err(boxlite_shared::errors::BoxliteError::InvalidArgument(_)) + )); + + assert!(matches!( + baseline.ensure_capability_policy_matches( + &ContainerCapabilities { + add: vec!["CAP_SYS_ADMIN".into()], + ..Default::default() + }, + "privileged-box" + ), + Err(boxlite_shared::errors::BoxliteError::InvalidArgument(_)) + )); } #[test] diff --git a/src/boxlite/src/runtime/rt_impl.rs b/src/boxlite/src/runtime/rt_impl.rs index 3311586d0..950263881 100644 --- a/src/boxlite/src/runtime/rt_impl.rs +++ b/src/boxlite/src/runtime/rt_impl.rs @@ -345,7 +345,8 @@ impl RuntimeImpl { /// /// Returns `(LiteBox, true)` if a new box was created, or `(LiteBox, false)` /// if an existing box with the given name was found. When an existing box is - /// returned, the provided `options` are ignored (no config drift validation). + /// returned, general options are ignored, but its capability policy must + /// match exactly so reuse cannot silently weaken or elevate privileges. pub async fn get_or_create( self: &Arc, options: BoxOptions, @@ -390,6 +391,10 @@ impl RuntimeImpl { && let Some((config, state)) = self.box_manager.lookup_box(name)? { return if reuse_existing { + options.ensure_capability_policy_matches( + &config.options.advanced.capabilities, + name, + )?; let (box_impl, _) = self.get_or_create_box_impl(config, state); Ok((litebox_from_impl(box_impl), false)) } else { @@ -430,6 +435,10 @@ impl RuntimeImpl { && let Some(ref name) = name && let Some((config, state)) = self.box_manager.lookup_box(name)? { + options.ensure_capability_policy_matches( + &config.options.advanced.capabilities, + name, + )?; let (box_impl, _) = self.get_or_create_box_impl(config, state); return Ok((litebox_from_impl(box_impl), false)); } diff --git a/src/boxlite/src/runtime/types.rs b/src/boxlite/src/runtime/types.rs index 2159c442f..b6285a78b 100644 --- a/src/boxlite/src/runtime/types.rs +++ b/src/boxlite/src/runtime/types.rs @@ -9,6 +9,7 @@ use std::fmt; use std::hash::Hash; pub use crate::litebox::{BoxState, BoxStatus, HealthStatus}; +use crate::runtime::advanced_options::ContainerCapabilities; use crate::runtime::id::BoxID; /// Re-exported here so the CLI can reach volume metadata the same way it /// reaches [`ImageInfo`] (`boxlite::runtime::types::VolumeInfo`). The type @@ -306,6 +307,17 @@ impl BoxLifecyclePolicy { } } +/// Expert-only public metadata about a box. +/// +/// This intentionally contains inspection-safe state only. Runtime security +/// configuration remains private to the owning runtime. +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(default)] +pub struct BoxAdvancedInfo { + /// Linux capability policy applied to the container process. + pub capabilities: ContainerCapabilities, +} + /// Public metadata about a box (returned by list operations). #[derive(Debug, Clone, Serialize, Deserialize)] pub struct BoxInfo { @@ -336,6 +348,10 @@ pub struct BoxInfo { /// Allocated memory in MiB. pub memory_mib: u32, + /// Expert-only inspection metadata. + #[serde(default)] + pub advanced: BoxAdvancedInfo, + /// User-defined labels for filtering and organization. pub labels: HashMap, @@ -375,6 +391,9 @@ impl BoxInfo { }, cpus: config.options.cpus.unwrap_or(DEFAULT_CPUS), memory_mib: config.options.memory_mib.unwrap_or(DEFAULT_MEMORY_MIB), + advanced: BoxAdvancedInfo { + capabilities: config.options.advanced.capabilities.clone(), + }, labels: HashMap::new(), // Local runtimes do not sweep lifecycle deadlines, but metadata keeps // the configured values so callers can inspect the effective policy. @@ -396,6 +415,7 @@ impl PartialEq for BoxInfo { && self.image == other.image && self.cpus == other.cpus && self.memory_mib == other.memory_mib + && self.advanced == other.advanced && self.labels == other.labels && self.auto_pause == other.auto_pause && self.auto_delete == other.auto_delete diff --git a/src/boxlite/tests/security_enforcement.rs b/src/boxlite/tests/security_enforcement.rs index 5c2d91313..2bee0bf2e 100644 --- a/src/boxlite/tests/security_enforcement.rs +++ b/src/boxlite/tests/security_enforcement.rs @@ -227,6 +227,77 @@ async fn capabilities_match_docker_defaults(bx: &LiteBox) { ); } +#[tokio::test(flavor = "multi_thread")] +async fn custom_capabilities_apply_to_init_and_non_tty_exec() { + const CAP_NET_RAW: u64 = 1 << 13; + const CAP_SYS_ADMIN: u64 = 1 << 21; + + let home = boxlite_test_utils::home::PerTestBoxHome::new(); + let runtime = BoxliteRuntime::new(BoxliteOptions { + home_dir: home.path.clone(), + image_registries: common::test_registries(), + }) + .expect("create runtime"); + + let bx = runtime + .create( + BoxOptions { + advanced: boxlite::AdvancedBoxOptions { + capabilities: boxlite::ContainerCapabilities { + add: vec!["SYS_ADMIN".into()], + drop: vec!["NET_RAW".into()], + }, + ..Default::default() + }, + rootfs: RootfsSpec::Image("alpine:latest".into()), + auto_delete: Some(0), + ..Default::default() + }, + None, + ) + .await + .expect("create box"); + bx.start().await.expect("start box"); + + let init_caps = exec_stdout( + &bx, + BoxCommand::new("sh").args(["-c", "grep '^CapEff:' /proc/1/status"]), + ) + .await; + assert_capability_change(&init_caps, CAP_SYS_ADMIN, CAP_NET_RAW, "PID 1"); + + let exec_caps = exec_stdout( + &bx, + BoxCommand::new("sh") + .args(["-c", "grep '^CapEff:' /proc/self/status"]) + .tty(false), + ) + .await; + assert_capability_change(&exec_caps, CAP_SYS_ADMIN, CAP_NET_RAW, "non-TTY exec"); + + bx.stop().await.expect("stop box"); + let _ = runtime.shutdown(Some(common::TEST_SHUTDOWN_TIMEOUT)).await; +} + +fn assert_capability_change(status: &str, added: u64, dropped: u64, process: &str) { + let cap_eff = status + .trim() + .strip_prefix("CapEff:\t") + .and_then(|hex| u64::from_str_radix(hex.trim(), 16).ok()) + .expect("CapEff should contain a hexadecimal capability mask"); + + assert_ne!( + cap_eff & added, + 0, + "CAP_SYS_ADMIN should be effective for {process}, CapEff=0x{cap_eff:x}" + ); + assert_eq!( + cap_eff & dropped, + 0, + "CAP_NET_RAW should not be effective for {process}, CapEff=0x{cap_eff:x}" + ); +} + // ============================================================================ // TEST: TSI isolation when network is disabled // ============================================================================ diff --git a/src/cli/README.md b/src/cli/README.md index 9b603db29..c8b518be9 100644 --- a/src/cli/README.md +++ b/src/cli/README.md @@ -322,6 +322,8 @@ silently restarting it, because restarting would run the command a second time. | `--volume VOLUME` | `-v` | Mount a volume (e.g. `hostPath:boxPath`, `boxPath` for anonymous) | | `--cpus N` | | CPU limit | | `--memory MiB` | | Memory limit (MiB) | +| `--cap-add CAPABILITY` | | Add a Linux capability (repeatable; accepts `CAP_` prefix or `ALL`) | +| `--cap-drop CAPABILITY` | | Drop a Linux capability (repeatable; accepts `CAP_` prefix or `ALL`) | | `--name NAME` | | Name the box | | `--detach` | `-d` | Run in background, print box ID | | `--rm` | | Remove the box when it exits | @@ -334,6 +336,7 @@ boxlite run -it --rm alpine:latest /bin/sh boxlite run -d --name openclaw -p 18789:18789 ghcr.io/openclaw/openclaw:main boxlite run -v /host/data:/app/data alpine:latest cat /app/data/hello.txt boxlite run --rootfs /path/to/rootfs /bin/sh +boxlite run --cap-drop ALL --cap-add NET_BIND_SERVICE nginx:alpine ``` ### `boxlite create` @@ -361,6 +364,8 @@ default, and `exec` still starts it on demand. | `--volume VOLUME` | `-v` | Mount a volume (e.g. `hostPath:boxPath`, or box path for anonymous) | | `--cpus N` | | CPU limit | | `--memory MiB` | | Memory limit (MiB) | +| `--cap-add CAPABILITY` | | Add a Linux capability (repeatable; accepts `CAP_` prefix or `ALL`) | +| `--cap-drop CAPABILITY` | | Drop a Linux capability (repeatable; accepts `CAP_` prefix or `ALL`) | | `--detach` | `-d` | (create always “detaches”) | | `--rm` | | Auto-remove when stopped | @@ -370,6 +375,7 @@ default, and `exec` still starts it on demand. boxlite create --name mybox alpine:latest boxlite create -p 18789:18789 -v /data:/app/data --name openclaw ghcr.io/openclaw/openclaw:main boxlite create --rootfs /path/to/rootfs --name local-rootfs +boxlite create --cap-drop NET_RAW --name hardened alpine:latest boxlite start mybox boxlite start openclaw ``` diff --git a/src/cli/src/cli.rs b/src/cli/src/cli.rs index 82d7239c3..ab340d9eb 100644 --- a/src/cli/src/cli.rs +++ b/src/cli/src/cli.rs @@ -402,6 +402,28 @@ impl ProcessFlags { } } +// ============================================================================ +// CAPABILITY FLAGS +// ============================================================================ + +#[derive(Args, Debug, Clone, Default)] +pub struct CapabilityFlags { + /// Add a Linux capability to the container (repeatable; `ALL` is supported) + #[arg(long = "cap-add", value_name = "CAPABILITY")] + pub cap_add: Vec, + + /// Drop a Linux capability from the container (repeatable; `ALL` is supported) + #[arg(long = "cap-drop", value_name = "CAPABILITY")] + pub cap_drop: Vec, +} + +impl CapabilityFlags { + pub fn apply_to(&self, opts: &mut BoxOptions) { + opts.advanced.capabilities.add.clone_from(&self.cap_add); + opts.advanced.capabilities.drop.clone_from(&self.cap_drop); + } +} + // ============================================================================ // RESOURCE FLAGS // ============================================================================ diff --git a/src/cli/src/commands/create.rs b/src/cli/src/commands/create.rs index a70c1e057..0dc09b856 100644 --- a/src/cli/src/commands/create.rs +++ b/src/cli/src/commands/create.rs @@ -1,4 +1,6 @@ -use crate::cli::{GlobalFlags, NetworkFlags, PublishFlags, ResourceFlags, VolumeFlags}; +use crate::cli::{ + CapabilityFlags, GlobalFlags, NetworkFlags, PublishFlags, ResourceFlags, VolumeFlags, +}; use boxlite::{BoxOptions, RootfsSpec}; use clap::Args; @@ -32,6 +34,9 @@ pub struct CreateArgs { #[command(flatten)] pub resource: ResourceFlags, + #[command(flatten)] + pub capability: CapabilityFlags, + #[command(flatten)] pub publish: PublishFlags, @@ -61,6 +66,7 @@ impl CreateArgs { fn to_box_options(&self, global: &GlobalFlags) -> anyhow::Result { let mut options = BoxOptions::default(); self.resource.apply_to(&mut options); + self.capability.apply_to(&mut options); self.management.apply_to(&mut options)?; self.publish.apply_to(&mut options)?; self.volume.apply_to(&mut options, global.home.as_deref())?; @@ -152,4 +158,27 @@ mod tests { assert!(err.to_string().contains("either IMAGE or --rootfs")); } + + #[test] + fn create_capability_flags_reach_box_options() { + let cli = Cli::try_parse_from([ + "boxlite", + "create", + "--cap-add", + "SYS_ADMIN", + "--cap-drop", + "CAP_NET_RAW", + "alpine", + ]) + .expect("capability flags should parse"); + let Commands::Create(args) = cli.command else { + panic!("expected create command"); + }; + + let opts = args + .to_box_options(&cli.global) + .expect("options should build"); + assert_eq!(opts.advanced.capabilities.add, vec!["SYS_ADMIN"]); + assert_eq!(opts.advanced.capabilities.drop, vec!["CAP_NET_RAW"]); + } } diff --git a/src/cli/src/commands/inspect.rs b/src/cli/src/commands/inspect.rs index 91bedec08..6b18392fa 100644 --- a/src/cli/src/commands/inspect.rs +++ b/src/cli/src/commands/inspect.rs @@ -41,6 +41,22 @@ struct InspectPresenter { cpus: u8, #[serde(rename = "Memory")] memory: u64, + #[serde(rename = "Advanced")] + advanced: InspectAdvancedPresenter, +} + +#[derive(Debug, Serialize)] +struct InspectAdvancedPresenter { + #[serde(rename = "Capabilities")] + capabilities: InspectCapabilitiesPresenter, +} + +#[derive(Debug, Serialize)] +struct InspectCapabilitiesPresenter { + #[serde(rename = "Add")] + add: Vec, + #[serde(rename = "Drop")] + drop: Vec, } #[derive(Debug, Serialize)] @@ -73,6 +89,12 @@ impl From<&BoxInfo> for InspectPresenter { }, cpus: info.cpus, memory: info.memory_mib as u64 * 1024 * 1024, + advanced: InspectAdvancedPresenter { + capabilities: InspectCapabilitiesPresenter { + add: info.advanced.capabilities.add.clone(), + drop: info.advanced.capabilities.drop.clone(), + }, + }, } } } @@ -224,3 +246,48 @@ fn write_inspect_output( } Ok(()) } + +#[cfg(test)] +mod tests { + use super::*; + use boxlite::{BoxID, BoxStatus, HealthStatus}; + use std::collections::HashMap; + + #[test] + fn inspect_serializes_capability_policy() { + let now = chrono::Utc::now(); + let info = BoxInfo { + id: BoxID::parse("inspect-capabilities").unwrap(), + name: Some("cap-box".into()), + status: BoxStatus::Configured, + created_at: now, + last_updated: now, + pid: None, + image: "alpine:latest".into(), + cpus: 1, + memory_mib: 512, + advanced: boxlite::BoxAdvancedInfo { + capabilities: boxlite::ContainerCapabilities { + add: vec!["SYS_ADMIN".into()], + drop: vec!["NET_RAW".into()], + }, + }, + labels: HashMap::new(), + auto_pause: 0, + auto_delete: 0, + auto_resume: true, + health_status: HealthStatus::new(), + exit_code: None, + }; + + let value = serde_json::to_value(InspectPresenter::from(&info)).unwrap(); + assert_eq!( + value["Advanced"]["Capabilities"]["Add"], + serde_json::json!(["SYS_ADMIN"]) + ); + assert_eq!( + value["Advanced"]["Capabilities"]["Drop"], + serde_json::json!(["NET_RAW"]) + ); + } +} diff --git a/src/cli/src/commands/run.rs b/src/cli/src/commands/run.rs index d3504daf6..639e0bae6 100644 --- a/src/cli/src/commands/run.rs +++ b/src/cli/src/commands/run.rs @@ -1,6 +1,6 @@ use crate::cli::{ - GlobalFlags, ManagementFlags, NetworkFlags, ProcessFlags, PublishFlags, ResourceFlags, - VolumeFlags, + CapabilityFlags, GlobalFlags, ManagementFlags, NetworkFlags, ProcessFlags, PublishFlags, + ResourceFlags, VolumeFlags, }; use crate::terminal::StreamManager; use crate::util::to_shell_exit_code; @@ -16,6 +16,9 @@ pub struct RunArgs { #[command(flatten)] pub resource: ResourceFlags, + #[command(flatten)] + pub capability: CapabilityFlags, + #[command(flatten)] pub publish: PublishFlags, @@ -121,6 +124,7 @@ impl BoxRunner { ) -> anyhow::Result { let mut options = BoxOptions::default(); self.args.resource.apply_to(&mut options); + self.args.capability.apply_to(&mut options); self.args.management.apply_to(&mut options)?; self.args.publish.apply_to(&mut options)?; self.args @@ -189,6 +193,28 @@ mod tests { use crate::cli::{Cli, Commands}; use clap::Parser; + #[test] + fn run_capability_flags_are_repeatable() { + let cli = Cli::try_parse_from([ + "boxlite", + "run", + "--cap-add", + "SYS_ADMIN", + "--cap-add", + "NET_ADMIN", + "--cap-drop", + "CAP_NET_RAW", + "alpine", + ]) + .expect("capability flags should parse"); + let Commands::Run(args) = cli.command else { + panic!("expected run command"); + }; + + assert_eq!(args.capability.cap_add, vec!["SYS_ADMIN", "NET_ADMIN"]); + assert_eq!(args.capability.cap_drop, vec!["CAP_NET_RAW"]); + } + #[test] fn run_rootfs_flag_sets_rootfs_path_and_uses_trailing_command() { let cli = Cli::try_parse_from(["boxlite", "run", "--rootfs", "/tmp/rootfs", "echo", "hi"]) diff --git a/src/cli/src/commands/serve/handlers/boxes.rs b/src/cli/src/commands/serve/handlers/boxes.rs index 904b2cdf0..ae0036041 100644 --- a/src/cli/src/commands/serve/handlers/boxes.rs +++ b/src/cli/src/commands/serve/handlers/boxes.rs @@ -17,6 +17,25 @@ pub(in crate::commands::serve) async fn create_box( State(state): State>, Json(req): Json, ) -> Response { + create_box_inner(state, req).await +} + +pub(in crate::commands::serve) async fn create_box_legacy( + State(state): State>, + Json(req): Json, +) -> Response { + if req.advanced.is_present { + return error_response( + StatusCode::BAD_REQUEST, + "advanced capabilities require POST /v1/boxes/strict".to_string(), + "InvalidArgumentError", + "invalid_argument", + ); + } + create_box_inner(state, req).await +} + +async fn create_box_inner(state: Arc, req: CreateBoxRequest) -> Response { let name = req.name.clone(); let options = match build_box_options(&req) { Ok(options) => options, diff --git a/src/cli/src/commands/serve/handlers/config.rs b/src/cli/src/commands/serve/handlers/config.rs index a33d5cd2a..1a10f3fb2 100644 --- a/src/cli/src/commands/serve/handlers/config.rs +++ b/src/cli/src/commands/serve/handlers/config.rs @@ -7,6 +7,7 @@ use super::super::types::{ServerCapabilities, ServerConfig}; pub(in crate::commands::serve) async fn get_config() -> Json { Json(ServerConfig { capabilities: ServerCapabilities { + linux_capabilities_enabled: true, snapshots_enabled: true, clone_enabled: true, export_enabled: true, diff --git a/src/cli/src/commands/serve/mod.rs b/src/cli/src/commands/serve/mod.rs index 0a8962ab3..92da59327 100644 --- a/src/cli/src/commands/serve/mod.rs +++ b/src/cli/src/commands/serve/mod.rs @@ -709,6 +709,12 @@ fn box_info_to_response(info: &BoxInfo) -> BoxResponse { image: info.image.clone(), cpus: info.cpus, memory_mib: info.memory_mib, + advanced: types::BoxAdvancedResponse { + capabilities: types::ContainerCapabilitiesResponse { + add: info.advanced.capabilities.add.clone(), + drop: info.advanced.capabilities.drop.clone(), + }, + }, labels: info.labels.clone(), auto_pause: info.auto_pause, auto_delete: info.auto_delete, @@ -766,6 +772,13 @@ fn build_box_options(req: &CreateBoxRequest) -> Result) -> Router { // Box CRUD (import first — static path before param path) .route("/v1/boxes/import", post(advanced::import_box)) .route( - "/v1/boxes", + "/v1/boxes/strict", post(boxes::create_box).get(boxes::list_boxes), ) + .route( + "/v1/boxes", + post(boxes::create_box_legacy).get(boxes::list_boxes), + ) .route( "/v1/boxes/{box_id}", get(boxes::get_box) .delete(boxes::remove_box) .head(boxes::head_box), ) + .route("/v1/boxes/{box_id}/strict", get(boxes::get_box)) // Box lifecycle .route( "/v1/boxes/{box_id}/start", @@ -1313,6 +1331,18 @@ mod tests { ); } + #[test] + fn build_box_options_carries_container_capabilities_from_the_wire() { + let req: super::types::CreateBoxRequest = serde_json::from_str( + r#"{"image":"alpine:latest","advanced":{"capabilities":{"add":["SYS_ADMIN"],"drop":["CAP_NET_RAW"]}}}"#, + ) + .expect("capability request must deserialize"); + + let opts = build_box_options(&req).expect("build capability options"); + assert_eq!(opts.advanced.capabilities.add, vec!["SYS_ADMIN"]); + assert_eq!(opts.advanced.capabilities.drop, vec!["CAP_NET_RAW"]); + } + #[test] fn build_box_options_carries_lifecycle_policy_and_uses_compatible_detach_default() { let req: super::types::CreateBoxRequest = serde_json::from_str( diff --git a/src/cli/src/commands/serve/types.rs b/src/cli/src/commands/serve/types.rs index 6ed682e32..15d55e263 100644 --- a/src/cli/src/commands/serve/types.rs +++ b/src/cli/src/commands/serve/types.rs @@ -39,6 +39,11 @@ pub(super) struct CreateBoxRequest { /// add one. #[serde(default)] pub tty: Option, + /// Expert-only container options. A small wrapper records presence so the + /// legacy route can reject this newer contract even when the object is + /// empty. Explicit `null` is rejected by the nested deserializer. + #[serde(default)] + pub advanced: AdvancedRequestField, #[serde(default)] pub network: Option, #[serde(default)] @@ -59,6 +64,38 @@ pub(super) struct CreateBoxRequest { // below for the wire-shape pin. } +#[derive(Default)] +pub(super) struct AdvancedRequestField { + pub is_present: bool, + pub capabilities: ContainerCapabilitiesRequest, +} + +impl<'de> Deserialize<'de> for AdvancedRequestField { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + let value = CreateBoxAdvancedOptions::deserialize(deserializer)?; + Ok(Self { + is_present: true, + capabilities: value.capabilities, + }) + } +} + +#[derive(Default, Deserialize)] +#[serde(default, deny_unknown_fields)] +struct CreateBoxAdvancedOptions { + capabilities: ContainerCapabilitiesRequest, +} + +#[derive(Clone, Default, Deserialize)] +#[serde(default, deny_unknown_fields)] +pub(super) struct ContainerCapabilitiesRequest { + pub add: Vec, + pub drop: Vec, +} + #[derive(Deserialize)] #[serde(deny_unknown_fields)] pub(super) struct NetworkSpec { @@ -78,6 +115,7 @@ pub(super) struct BoxResponse { pub image: String, pub cpus: u8, pub memory_mib: u32, + pub advanced: BoxAdvancedResponse, pub labels: HashMap, pub auto_pause: u32, pub auto_delete: u32, @@ -89,6 +127,17 @@ pub(super) struct BoxResponse { pub exit_code: Option, } +#[derive(Serialize)] +pub(super) struct BoxAdvancedResponse { + pub capabilities: ContainerCapabilitiesResponse, +} + +#[derive(Serialize)] +pub(super) struct ContainerCapabilitiesResponse { + pub add: Vec, + pub drop: Vec, +} + #[derive(Serialize)] pub(super) struct ListBoxesResponse { pub boxes: Vec, @@ -161,6 +210,7 @@ pub(super) struct ServerConfig { #[derive(Serialize)] pub(super) struct ServerCapabilities { + pub linux_capabilities_enabled: bool, pub snapshots_enabled: bool, pub clone_enabled: bool, pub export_enabled: bool, diff --git a/src/guest/src/container/capabilities.rs b/src/guest/src/container/capabilities.rs index e3bb326d8..7c6c920cb 100644 --- a/src/guest/src/container/capabilities.rs +++ b/src/guest/src/container/capabilities.rs @@ -1,76 +1,261 @@ //! Linux capabilities for container processes. //! -//! Defines the default capability set matching Docker/OCI defaults. +//! Defines BoxLite's Docker-compatible baseline and custom capability policy. //! Used by: //! - OCI spec builder (process.capabilities) //! - Tenant process spawning (exec capabilities) -use oci_spec::runtime::Capability; +use boxlite_shared::errors::{BoxliteError, BoxliteResult}; +use oci_spec::runtime::{Capability, LinuxCapabilities, LinuxCapabilitiesBuilder}; +use serde::{Deserialize, Serialize}; use std::collections::HashSet; -/// Default capabilities for container processes. +// Linux UAPI capability numbers, in order from 0 through CAP_LAST_CAP. +const CAPABILITIES_BY_NUMBER: [Capability; 41] = [ + Capability::Chown, + Capability::DacOverride, + Capability::DacReadSearch, + Capability::Fowner, + Capability::Fsetid, + Capability::Kill, + Capability::Setgid, + Capability::Setuid, + Capability::Setpcap, + Capability::LinuxImmutable, + Capability::NetBindService, + Capability::NetBroadcast, + Capability::NetAdmin, + Capability::NetRaw, + Capability::IpcLock, + Capability::IpcOwner, + Capability::SysModule, + Capability::SysRawio, + Capability::SysChroot, + Capability::SysPtrace, + Capability::SysPacct, + Capability::SysAdmin, + Capability::SysBoot, + Capability::SysNice, + Capability::SysResource, + Capability::SysTime, + Capability::SysTtyConfig, + Capability::Mknod, + Capability::Lease, + Capability::AuditWrite, + Capability::AuditControl, + Capability::Setfcap, + Capability::MacOverride, + Capability::MacAdmin, + Capability::Syslog, + Capability::WakeAlarm, + Capability::BlockSuspend, + Capability::AuditRead, + Capability::Perfmon, + Capability::Bpf, + Capability::CheckpointRestore, +]; + +/// The resolved Linux capability policy for every process in one container. /// -/// Matches Docker's default capability set — sufficient for most workloads -/// while excluding dangerous capabilities like CAP_SYS_ADMIN (mount/remount, -/// namespace manipulation), CAP_NET_ADMIN (network reconfiguration), -/// CAP_SYS_MODULE (kernel module loading), and CAP_BPF. -pub fn default_capabilities() -> HashSet { - [ - Capability::Chown, - Capability::DacOverride, - Capability::Fowner, - Capability::Fsetid, - Capability::Kill, - Capability::Setgid, - Capability::Setuid, - Capability::Setpcap, - Capability::NetBindService, - Capability::NetRaw, - Capability::SysChroot, - Capability::Mknod, - Capability::AuditWrite, - Capability::Setfcap, - ] - .into_iter() - .collect() +/// This is the guest's single semantic boundary: external APIs carry familiar +/// add/drop strings, then this type parses and resolves them once before the +/// OCI spec or any exec process is built. +#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] +#[serde(transparent)] +pub(crate) struct CapabilitySet(HashSet); + +impl Default for CapabilitySet { + fn default() -> Self { + Self( + [ + Capability::Chown, + Capability::DacOverride, + Capability::Fowner, + Capability::Fsetid, + Capability::Kill, + Capability::Setgid, + Capability::Setuid, + Capability::Setpcap, + Capability::NetBindService, + Capability::NetRaw, + Capability::SysChroot, + Capability::Mknod, + Capability::AuditWrite, + Capability::Setfcap, + ] + .into_iter() + .collect(), + ) + } +} + +impl CapabilitySet { + /// Resolve Docker-style capability additions and removals. + /// + /// Names are case-insensitive and may include the `CAP_` prefix. `ALL` in + /// additions starts from every capability supported by this guest; `ALL` + /// in removals starts from an empty set. Without `ALL`, removals are applied + /// before additions, so an explicitly added capability wins a named + /// conflict. With `add=ALL`, named drops win, matching Moby exactly. + pub(crate) fn resolve(add: &[String], drop: &[String]) -> BoxliteResult { + let additions = parse_capabilities(add)?; + let removals = parse_capabilities(drop)?; + + let supported = if add.is_empty() && drop.is_empty() { + None + } else { + Some(supported_capabilities()?) + }; + if let Some(supported) = &supported { + ensure_supported(&additions, supported)?; + ensure_supported(&removals, supported)?; + } + + if add.iter().any(|name| is_all(name)) { + let mut resolved = supported.ok_or_else(|| { + BoxliteError::Internal( + "capability support was not resolved for add=ALL".to_string(), + ) + })?; + for capability in removals { + resolved.remove(&capability); + } + return Ok(Self(resolved)); + } + + if drop.iter().any(|name| is_all(name)) { + return Ok(Self(additions)); + } + + let mut resolved = Self::default().0; + for capability in removals { + resolved.remove(&capability); + } + resolved.extend(additions); + Ok(Self(resolved)) + } + + /// Build the exact OCI sets used by BoxLite's high-level add/drop policy. + /// + /// Docker/containerd apply ordinary container capabilities to bounding, + /// effective, and permitted. Inheritable and ambient are deliberately + /// absent: they require a separate security contract and implicitly + /// populating them has caused runtime vulnerabilities during exec. + pub(crate) fn to_oci(&self) -> BoxliteResult { + let mut capabilities = LinuxCapabilitiesBuilder::default() + .bounding(self.0.clone()) + .effective(self.0.clone()) + .permitted(self.0.clone()) + .build() + .map_err(|error| { + BoxliteError::Internal(format!("failed to build Linux capabilities: {error}")) + })?; + + // oci-spec 0.6's builder defaults these sets to three capabilities. + capabilities.set_inheritable(None); + capabilities.set_ambient(None); + Ok(capabilities) + } + + /// Canonical names accepted by libcontainer's tenant builder. + pub(crate) fn names(&self) -> Vec { + let mut names: Vec = self + .0 + .iter() + .map(|capability| format!("CAP_{capability}")) + .collect(); + names.sort_unstable(); + names + } + + #[cfg(test)] + fn len(&self) -> usize { + self.0.len() + } + + #[cfg(test)] + fn contains(&self, capability: &Capability) -> bool { + self.0.contains(capability) + } + + #[cfg(test)] + fn iter(&self) -> impl Iterator { + self.0.iter() + } +} + +fn parse_capabilities(names: &[String]) -> BoxliteResult> { + names + .iter() + .filter(|name| !is_all(name)) + .map(|name| parse_capability(name)) + .collect() +} + +fn parse_capability(name: &str) -> BoxliteResult { + let normalized = name.to_ascii_uppercase(); + let unprefixed = normalized.strip_prefix("CAP_").unwrap_or(&normalized); + unprefixed + .parse::() + .map_err(|_| BoxliteError::InvalidArgument(format!("unknown Linux capability '{name}'"))) +} + +fn is_all(name: &str) -> bool { + name.eq_ignore_ascii_case("ALL") || name.eq_ignore_ascii_case("CAP_ALL") } -/// Convert default capabilities to string names for libcontainer API. -pub fn capability_names() -> Vec { - [ - "CAP_CHOWN", - "CAP_DAC_OVERRIDE", - "CAP_FOWNER", - "CAP_FSETID", - "CAP_KILL", - "CAP_SETGID", - "CAP_SETUID", - "CAP_SETPCAP", - "CAP_NET_BIND_SERVICE", - "CAP_NET_RAW", - "CAP_SYS_CHROOT", - "CAP_MKNOD", - "CAP_AUDIT_WRITE", - "CAP_SETFCAP", - ] - .iter() - .map(|s| s.to_string()) - .collect() +fn supported_capabilities() -> BoxliteResult> { + let raw = std::fs::read_to_string("/proc/sys/kernel/cap_last_cap").map_err(|error| { + BoxliteError::Internal(format!( + "failed to read guest kernel capability ceiling: {error}" + )) + })?; + let last_capability = raw.trim().parse::().map_err(|error| { + BoxliteError::Internal(format!( + "invalid guest kernel capability ceiling '{}': {error}", + raw.trim() + )) + })?; + + Ok(CAPABILITIES_BY_NUMBER + .iter() + .take(last_capability.saturating_add(1)) + .copied() + .collect()) +} + +fn ensure_supported( + requested: &HashSet, + supported: &HashSet, +) -> BoxliteResult<()> { + if let Some(capability) = requested + .iter() + .find(|capability| !supported.contains(capability)) + { + return Err(BoxliteError::InvalidArgument(format!( + "Linux capability 'CAP_{capability}' is not supported by the guest kernel" + ))); + } + Ok(()) } #[cfg(test)] mod tests { use super::*; + fn names(values: &[&str]) -> Vec { + values.iter().map(|value| (*value).to_string()).collect() + } + #[test] fn default_capabilities_has_14_docker_defaults() { - let caps = default_capabilities(); + let caps = CapabilitySet::default(); assert_eq!(caps.len(), 14); } #[test] fn default_capabilities_includes_required_caps() { - let caps = default_capabilities(); + let caps = CapabilitySet::default(); let required = [ Capability::Chown, Capability::DacOverride, @@ -94,7 +279,7 @@ mod tests { #[test] fn default_capabilities_excludes_dangerous_caps() { - let caps = default_capabilities(); + let caps = CapabilitySet::default(); let dangerous = [ Capability::SysAdmin, Capability::NetAdmin, @@ -113,8 +298,8 @@ mod tests { #[test] fn capability_names_match_default_capabilities() { - let caps = default_capabilities(); - let names = capability_names(); + let caps = CapabilitySet::default(); + let names = caps.names(); assert_eq!(caps.len(), names.len()); for name in &names { assert!( @@ -127,8 +312,117 @@ mod tests { #[test] fn capability_names_are_all_uppercase() { - for name in capability_names() { + for name in CapabilitySet::default().names() { assert_eq!(name, name.to_uppercase(), "should be uppercase: {}", name); } } + + #[test] + fn kernel_capability_table_matches_linux_uapi_numbers() { + assert_eq!(CAPABILITIES_BY_NUMBER[0], Capability::Chown); + assert_eq!(CAPABILITIES_BY_NUMBER[31], Capability::Setfcap); + assert_eq!(CAPABILITIES_BY_NUMBER[37], Capability::AuditRead); + assert_eq!(CAPABILITIES_BY_NUMBER[40], Capability::CheckpointRestore); + } + + #[test] + fn resolve_capabilities_without_overrides_preserves_docker_defaults() { + let resolved = CapabilitySet::resolve(&[], &[]).expect("resolve default capabilities"); + + assert_eq!(resolved, CapabilitySet::default()); + assert_eq!(resolved.len(), 14); + } + + #[test] + fn resolve_capabilities_adds_docker_style_name() { + let resolved = + CapabilitySet::resolve(&names(&["SYS_ADMIN"]), &[]).expect("resolve added capability"); + + assert_eq!(resolved.len(), 15); + assert!(resolved.contains(&Capability::SysAdmin)); + assert!(resolved.contains(&Capability::NetRaw)); + } + + #[test] + fn resolve_capabilities_drops_docker_style_name() { + let resolved = + CapabilitySet::resolve(&[], &names(&["NET_RAW"])).expect("resolve dropped capability"); + + assert_eq!(resolved.len(), 13); + assert!(!resolved.contains(&Capability::NetRaw)); + assert!(resolved.contains(&Capability::NetBindService)); + } + + #[test] + fn drop_all_then_add_keeps_only_explicit_additions() { + let resolved = CapabilitySet::resolve(&names(&["SYS_ADMIN"]), &names(&["ALL"])) + .expect("resolve drop ALL with one addition"); + + assert_eq!( + resolved, + CapabilitySet([Capability::SysAdmin].into_iter().collect()) + ); + } + + #[test] + fn cap_prefixed_all_is_the_all_sentinel() { + assert!(is_all("CAP_ALL")); + assert!(is_all("cap_all")); + } + + #[test] + fn add_all_then_drop_removes_only_explicit_drops() { + let all = CapabilitySet::resolve(&names(&["ALL"]), &[]).expect("resolve ALL capabilities"); + let resolved = CapabilitySet::resolve(&names(&["ALL"]), &names(&["NET_RAW"])) + .expect("resolve ALL capabilities with one drop"); + + assert!(all.contains(&Capability::SysAdmin)); + assert!(all.contains(&Capability::NetRaw)); + assert_eq!(resolved.len(), all.len() - 1); + assert!(!resolved.contains(&Capability::NetRaw)); + assert!(all + .iter() + .filter(|capability| **capability != Capability::NetRaw) + .all(|capability| resolved.contains(capability))); + } + + #[test] + fn resolve_capabilities_rejects_unknown_name() { + let error = CapabilitySet::resolve(&names(&["CAP_NOT_REAL"]), &[]) + .expect_err("unknown capability must fail"); + + assert!( + error.to_string().contains("CAP_NOT_REAL"), + "error should identify the invalid capability: {error}" + ); + } + + #[test] + fn explicit_add_wins_when_capability_is_also_dropped() { + let resolved = CapabilitySet::resolve(&names(&["SYS_ADMIN"]), &names(&["CAP_SYS_ADMIN"])) + .expect("resolve conflicting capability overrides"); + + assert!(resolved.contains(&Capability::SysAdmin)); + } + + #[test] + fn capability_names_are_case_insensitive_prefixed_and_deduplicated() { + let resolved = CapabilitySet::resolve( + &names(&["sys_admin", "CAP_SYS_ADMIN", "cap_net_admin"]), + &[], + ) + .expect("resolve normalized capability names"); + + assert_eq!(resolved.len(), CapabilitySet::default().len() + 2); + assert!(resolved.contains(&Capability::SysAdmin)); + assert!(resolved.contains(&Capability::NetAdmin)); + } + + #[test] + fn add_all_ignores_explicit_additions_after_specific_drops() { + let resolved = CapabilitySet::resolve(&names(&["ALL", "MKNOD"]), &names(&["MKNOD"])) + .expect("resolve ALL with redundant explicit addition"); + + assert!(!resolved.contains(&Capability::Mknod)); + } } diff --git a/src/guest/src/container/command.rs b/src/guest/src/container/command.rs index e10a876d2..3f1d91a1f 100644 --- a/src/guest/src/container/command.rs +++ b/src/guest/src/container/command.rs @@ -3,6 +3,7 @@ //! Provides a builder pattern for spawning processes inside containers, //! following the `std::process::Command` pattern. +use super::capabilities::CapabilitySet; use super::zygote::{self, BuildSpec}; use crate::service::exec::exec_handle::{ExecHandle, PtyConfig}; use boxlite_shared::errors::{BoxliteError, BoxliteResult}; @@ -57,6 +58,9 @@ pub struct ContainerCommand { /// Rootfs path for resolving user overrides from /etc/passwd. rootfs: Option, + /// Resolved capability set inherited by every exec process. + capabilities: CapabilitySet, + /// Working directory (None = use default "/") cwd: Option, @@ -78,6 +82,7 @@ impl ContainerCommand { env: HashMap, user: (u32, u32), rootfs: PathBuf, + capabilities: CapabilitySet, ) -> Self { Self { program: None, @@ -86,6 +91,7 @@ impl ContainerCommand { user, user_override: None, rootfs: Some(rootfs), + capabilities, cwd: None, console_socket: None, pty_config: None, @@ -386,6 +392,7 @@ impl ContainerCommand { args: container_args.clone(), uid, gid, + capabilities: self.capabilities.clone(), }; // Blocking IPC to zygote — use spawn_blocking to not block tokio. @@ -560,6 +567,7 @@ mod tests { HashMap::new(), (0, 0), PathBuf::from("/tmp/rootfs"), + CapabilitySet::default(), ) } diff --git a/src/guest/src/container/lifecycle.rs b/src/guest/src/container/lifecycle.rs index de6ebe8e4..a7b16cda2 100644 --- a/src/guest/src/container/lifecycle.rs +++ b/src/guest/src/container/lifecycle.rs @@ -3,6 +3,7 @@ //! Provides container creation, startup, and status checking using libcontainer. //! Follows the OCI Runtime Specification. +use super::capabilities::CapabilitySet; use super::command::ContainerCommand; use super::spec::UserMount; use super::stdio::{ContainerStdio, InitIo}; @@ -62,6 +63,8 @@ pub struct Container { env: HashMap, /// Resolved (uid, gid) from image USER directive, propagated to exec commands. user: (u32, u32), + /// Resolved capability set shared by init and every exec process. + capabilities: CapabilitySet, /// Stdio pipes that keep init process alive. /// Dropping this closes pipes → init gets EOF → init exits. #[allow(dead_code)] @@ -89,6 +92,7 @@ impl Container { /// - `env`: Environment variables in "KEY=VALUE" format /// - `workdir`: Working directory inside container /// - `user_mounts`: Bind mounts from guest VM paths into container + /// - `capabilities`: capability policy resolved at the RPC boundary /// /// # Errors /// @@ -106,6 +110,7 @@ impl Container { user: &str, user_mounts: Vec, tty: bool, + capabilities: CapabilitySet, ) -> BoxliteResult { let rootfs = rootfs.as_ref(); let workdir = workdir.as_ref(); @@ -178,6 +183,7 @@ impl Container { workdir, uid, gid, + &capabilities, &layout.containers_dir(), &user_mounts, tty, @@ -218,6 +224,7 @@ impl Container { bundle_path, env: env_map, user: (uid, gid), + capabilities, stdio, is_shutdown: std::sync::atomic::AtomicBool::new(false), }) @@ -366,6 +373,7 @@ impl Container { self.env.clone(), self.user, self.bundle_path.join("rootfs"), + self.capabilities.clone(), ) } diff --git a/src/guest/src/container/mod.rs b/src/guest/src/container/mod.rs index 9695fab96..8b33e00ba 100644 --- a/src/guest/src/container/mod.rs +++ b/src/guest/src/container/mod.rs @@ -76,6 +76,8 @@ mod stdio; #[cfg(target_os = "linux")] pub(crate) mod zygote; +#[cfg(target_os = "linux")] +pub(crate) use capabilities::CapabilitySet; #[cfg(target_os = "linux")] pub(crate) use command::SpawnResult; #[cfg(target_os = "linux")] diff --git a/src/guest/src/container/spec.rs b/src/guest/src/container/spec.rs index 6b096df0c..8f6c18115 100644 --- a/src/guest/src/container/spec.rs +++ b/src/guest/src/container/spec.rs @@ -2,14 +2,14 @@ //! //! Creates OCI-compliant runtime specifications following the runtime-spec standard. -use super::capabilities::default_capabilities; +use super::capabilities::CapabilitySet; use boxlite_shared::errors::{BoxliteError, BoxliteResult}; use std::path::Path; use oci_spec::runtime::{ - LinuxBuilder, LinuxCapabilitiesBuilder, LinuxIdMappingBuilder, LinuxNamespaceBuilder, - LinuxNamespaceType, Mount, MountBuilder, PosixRlimitBuilder, PosixRlimitType, ProcessBuilder, - RootBuilder, Spec, SpecBuilder, UserBuilder, + LinuxBuilder, LinuxIdMappingBuilder, LinuxNamespaceBuilder, LinuxNamespaceType, Mount, + MountBuilder, PosixRlimitBuilder, PosixRlimitType, ProcessBuilder, RootBuilder, Spec, + SpecBuilder, UserBuilder, }; /// User-specified bind mount for container @@ -32,7 +32,7 @@ pub struct UserMount { /// Builds an OCI spec with: /// - Standard mounts (/proc, /dev, /sys, etc.) /// - User-specified bind mounts (volumes) -/// - Default capabilities (matching runc defaults) +/// - Resolved default and user-requested capabilities /// - Standard namespaces (pid, ipc, uts, mount) /// - UID/GID mappings for user namespace /// - Configurable user (resolved uid/gid) @@ -52,11 +52,12 @@ pub fn create_oci_spec( workdir: &str, uid: u32, gid: u32, + capabilities: &CapabilitySet, bundle_path: &Path, user_mounts: &[UserMount], tty: bool, ) -> BoxliteResult { - let caps = build_default_capabilities()?; + let caps = capabilities.to_oci()?; let namespaces = build_default_namespaces()?; let mut mounts = build_standard_mounts(bundle_path)?; @@ -265,20 +266,6 @@ fn find_group_in_group_file(rootfs: &str, name: &str) -> BoxliteResult { // Spec Component Builders // ==================== -/// Build default Linux capabilities matching Docker/OCI defaults. -fn build_default_capabilities() -> BoxliteResult { - let caps = default_capabilities(); - - LinuxCapabilitiesBuilder::default() - .bounding(caps.clone()) - .effective(caps.clone()) - .inheritable(caps.clone()) - .permitted(caps.clone()) - .ambient(caps) - .build() - .map_err(|e| BoxliteError::Internal(format!("Failed to build capabilities: {}", e))) -} - /// Build default namespaces for container isolation fn build_default_namespaces() -> BoxliteResult> { Ok(vec![ @@ -358,6 +345,7 @@ pub(crate) fn build_tty_exec_process( cwd: &str, uid: u32, gid: u32, + capabilities: CapabilitySet, ) -> BoxliteResult { let user = UserBuilder::default() .uid(uid) @@ -371,7 +359,7 @@ pub(crate) fn build_tty_exec_process( .args(args.to_vec()) .env(env) .cwd(cwd) - .capabilities(build_default_capabilities()?) + .capabilities(capabilities.to_oci()?) .no_new_privileges(false) .build() .map_err(|e| BoxliteError::Internal(format!("Failed to build tty exec process: {}", e))) @@ -586,6 +574,7 @@ fn build_standard_mounts(bundle_path: &Path) -> BoxliteResult> { #[cfg(test)] mod tests { use super::*; + use crate::container::capabilities::CapabilitySet; use std::fs; /// Create a temp rootfs with /etc/passwd and /etc/group for testing. @@ -628,6 +617,36 @@ mod tests { dir } + #[test] + fn tty_exec_process_uses_resolved_capabilities() { + let resolved = CapabilitySet::resolve(&["SYS_ADMIN".to_string()], &["NET_RAW".to_string()]) + .expect("resolve capabilities for tty exec"); + let expected = resolved + .to_oci() + .expect("build expected OCI capability sets"); + + let process = build_tty_exec_process( + &["sh".to_string()], + &["PATH=/bin".to_string()], + "/", + 0, + 0, + resolved.clone(), + ) + .expect("build tty exec process"); + + assert_eq!(process.terminal(), Some(true)); + let capabilities = process + .capabilities() + .as_ref() + .expect("tty exec process should have capabilities"); + assert_eq!(capabilities.bounding(), expected.bounding()); + assert_eq!(capabilities.effective(), expected.effective()); + assert_eq!(capabilities.permitted(), expected.permitted()); + assert_eq!(capabilities.inheritable(), &None); + assert_eq!(capabilities.ambient(), &None); + } + // ================== // Empty / root // ================== diff --git a/src/guest/src/container/start.rs b/src/guest/src/container/start.rs index f171e058b..468be6085 100644 --- a/src/guest/src/container/start.rs +++ b/src/guest/src/container/start.rs @@ -3,6 +3,7 @@ //! Provides setup, validation, and execution functions for starting containers. //! Separated from container.rs to group by lifecycle phase (Prepare → Execute). +use super::capabilities::CapabilitySet; use super::spec; use super::zygote; use boxlite_shared::errors::{BoxliteError, BoxliteResult}; @@ -102,6 +103,7 @@ pub(crate) fn create_oci_bundle( workdir: &Path, uid: u32, gid: u32, + capabilities: &CapabilitySet, bundle_root: &Path, user_mounts: &[spec::UserMount], tty: bool, @@ -132,6 +134,7 @@ pub(crate) fn create_oci_bundle( .ok_or_else(|| BoxliteError::Internal("Invalid workdir path".to_string()))?, uid, gid, + capabilities, &bundle_path, user_mounts, tty, diff --git a/src/guest/src/container/zygote.rs b/src/guest/src/container/zygote.rs index d91eac540..89bd3901f 100644 --- a/src/guest/src/container/zygote.rs +++ b/src/guest/src/container/zygote.rs @@ -11,7 +11,7 @@ //! //! See `docs/investigations/concurrent-exec-deadlock.md` for full analysis. -use super::capabilities::capability_names; +use super::capabilities::CapabilitySet; use boxlite_shared::errors::{BoxliteError, BoxliteResult}; use libcontainer::container::builder::ContainerBuilder; use libcontainer::syscall::syscall::SyscallType; @@ -58,6 +58,7 @@ pub(crate) struct BuildSpec { pub args: Vec, pub uid: u32, pub gid: u32, + pub capabilities: CapabilitySet, } /// What init to build. Serialized over IPC to the zygote. @@ -265,9 +266,15 @@ fn do_build(spec: BuildSpec, fds: Option<[RawFd; 3]>) -> BuildResult { // The PTY path passes no stdio fds — youki wires the PTY slave instead. let env_vec: Vec = spec.env.iter().map(|(k, v)| format!("{k}={v}")).collect(); let cwd = spec.cwd.to_str().unwrap_or("/"); - let process = - super::spec::build_tty_exec_process(&spec.args, &env_vec, cwd, spec.uid, spec.gid) - .map_err(|e| format!("build tty exec process: {e}"))?; + let process = super::spec::build_tty_exec_process( + &spec.args, + &env_vec, + cwd, + spec.uid, + spec.gid, + spec.capabilities.clone(), + ) + .map_err(|e| format!("build tty exec process: {e}"))?; let process_json = serde_json::to_vec(&process) .map_err(|e| format!("serialize tty process.json: {e}"))?; let process_path = spec @@ -292,7 +299,7 @@ fn do_build(spec: BuildSpec, fds: Option<[RawFd; 3]>) -> BuildResult { // CLONE_PARENT so the tenant reparents to guest main (not the // zygote); guest main's reaper owns its exit. The zygote never waits. .as_sibling(true) - .with_capabilities(capability_names()) + .with_capabilities(spec.capabilities.names()) .with_no_new_privs(false) .with_detach(false) .with_cwd(Some(spec.cwd)) @@ -555,6 +562,7 @@ fn recv_response(sock: RawFd) -> BoxliteResult { #[cfg(test)] mod tests { use super::*; + use crate::container::capabilities::CapabilitySet; // ======================================================================== // Serialization tests (pure logic, no fork needed) @@ -578,6 +586,7 @@ mod tests { ], uid: 1000, gid: 1000, + capabilities: CapabilitySet::default(), } } @@ -633,6 +642,7 @@ mod tests { args: vec![], uid: 0, gid: 0, + capabilities: CapabilitySet::default(), }; let json = serde_json::to_vec(&spec).unwrap(); let decoded: BuildSpec = serde_json::from_slice(&json).unwrap(); @@ -790,6 +800,7 @@ mod tests { args, uid: 65534, gid: 65534, + capabilities: CapabilitySet::default(), }; send_request(fd_a, &ZygoteRequest::Build(spec.clone()), None).unwrap(); @@ -819,6 +830,7 @@ mod tests { args: vec![], uid: 0, gid: 0, + capabilities: CapabilitySet::default(), }; let (a, _b) = socketpair( @@ -968,6 +980,7 @@ mod tests { args: vec!["echo".to_string()], uid: 0, gid: 0, + capabilities: CapabilitySet::default(), }; z.build(spec, None).unwrap() })); @@ -1008,6 +1021,7 @@ mod tests { args: vec!["true".to_string()], uid: 0, gid: 0, + capabilities: CapabilitySet::default(), } } @@ -1350,6 +1364,7 @@ mod tests { args: vec!["true".to_string()], uid: 0, gid: 0, + capabilities: CapabilitySet::default(), }; do_build(spec, Some(fds)) }); diff --git a/src/guest/src/service/container.rs b/src/guest/src/service/container.rs index c8af7db7e..c83b4fa66 100644 --- a/src/guest/src/service/container.rs +++ b/src/guest/src/service/container.rs @@ -6,6 +6,7 @@ use std::path::Path; use crate::service::server::GuestServer; +use boxlite_shared::errors::BoxliteError; use boxlite_shared::{ container_init_response, container_start_response, rootfs_init, Container as ContainerService, ContainerInitError, ContainerInitRequest, ContainerInitResponse, ContainerInitSuccess, @@ -15,7 +16,7 @@ use nix::mount::{mount, MsFlags}; use tonic::{Request, Response, Status}; use tracing::{debug, error, info, warn}; -use crate::container::{Container, UserMount}; +use crate::container::{CapabilitySet, Container, UserMount}; use crate::layout::GuestLayout; use crate::storage::block_device::BlockDeviceMount; @@ -149,6 +150,17 @@ impl ContainerService for GuestServer { })); } + // Validate and resolve the privilege policy before creating directories, + // mounting the rootfs, or modifying its trust store. A malformed or + // unsupported name is caller input, not a partially initialized box. + let capability_policy = config + .advanced + .unwrap_or_default() + .capabilities + .unwrap_or_default(); + let capabilities = CapabilitySet::resolve(&capability_policy.add, &capability_policy.drop) + .map_err(BoxliteError::into_validation_status)?; + info!("🚀 Starting OCI container with received configuration"); // Compute rootfs paths from container_id @@ -295,6 +307,7 @@ impl ContainerService for GuestServer { &config.user, user_mounts, config.tty, + capabilities, ) { Ok(mut container) => { // Init is created, not yet running — Init never runs it; the diff --git a/src/guest/src/service/guest.rs b/src/guest/src/service/guest.rs index 72bb6739a..a1c5ed5ad 100644 --- a/src/guest/src/service/guest.rs +++ b/src/guest/src/service/guest.rs @@ -83,6 +83,9 @@ impl GuestService for GuestServer { debug!("Received ping request"); Ok(Response::new(PingResponse { version: env!("CARGO_PKG_VERSION").to_string(), + features: vec![ + boxlite_shared::constants::guest_features::LINUX_CAPABILITIES_V2.to_string(), + ], })) } diff --git a/src/shared/proto/boxlite/v1/service.proto b/src/shared/proto/boxlite/v1/service.proto index 4069a9483..8b3ac205a 100644 --- a/src/shared/proto/boxlite/v1/service.proto +++ b/src/shared/proto/boxlite/v1/service.proto @@ -184,6 +184,9 @@ message PingRequest {} message PingResponse { string version = 1; // Guest agent version + // Optional capabilities used for host/guest rollout negotiation. Unknown + // entries are ignored; an older guest decodes as an empty list. + repeated string features = 2; } message ShutdownRequest {} @@ -307,6 +310,27 @@ message ContainerConfig { // guest opens the PTY at a default size and the attaching client's // ResizeTty sets the real one. bool tty = 5; + + // Tags used by an early flat capability-policy prototype. They must not be + // reused because repeated strings and nested messages share the same wire + // type and could be silently misread by mixed-version peers. + reserved 6, 7; + reserved "cap_add", "cap_drop"; + + // Expert-only container process options. + ContainerAdvancedOptions advanced = 8; +} + +message ContainerAdvancedOptions { + ContainerCapabilities capabilities = 1; +} + +message ContainerCapabilities { + // Capabilities to add to the default capability set. + repeated string add = 1; + + // Capabilities to remove from the resulting capability set. + repeated string drop = 2; } // ============================================================================ diff --git a/src/shared/src/constants.rs b/src/shared/src/constants.rs index 435d407cb..9d7483d7c 100644 --- a/src/shared/src/constants.rs +++ b/src/shared/src/constants.rs @@ -40,6 +40,12 @@ pub mod executor { pub const CONTAINER_KEY: &str = "container"; } +/// Features advertised by the guest agent during Ping. +pub mod guest_features { + /// Guest understands Docker-style capability deltas on Container.Init. + pub const LINUX_CAPABILITIES_V2: &str = "linux-capabilities-v2"; +} + /// Virtiofs mount tags /// /// These tags identify shared filesystems mounted via virtiofs. diff --git a/src/shared/src/errors.rs b/src/shared/src/errors.rs index 10eefaa67..f7d90be8b 100644 --- a/src/shared/src/errors.rs +++ b/src/shared/src/errors.rs @@ -152,6 +152,17 @@ impl From for BoxliteError { /// 500 because they indicate a server-side bug or data-plane /// corruption — not a recoverable condition. impl BoxliteError { + /// Convert an error returned by request validation into a gRPC status. + /// + /// Validators use [`BoxliteError::InvalidArgument`] for caller mistakes; + /// other variants indicate that validation itself could not be completed. + pub fn into_validation_status(self) -> tonic::Status { + match self { + BoxliteError::InvalidArgument(message) => tonic::Status::invalid_argument(message), + error => tonic::Status::internal(error.to_string()), + } + } + pub fn http(&self) -> (u16, &'static str, &'static str) { match self { BoxliteError::InvalidArgument(_) => (400, "InvalidArgumentError", "invalid_argument"), @@ -186,6 +197,22 @@ impl BoxliteError { mod tests { use super::*; + #[test] + fn validation_status_distinguishes_caller_input_from_internal_failure() { + let invalid = + BoxliteError::InvalidArgument("unknown capability".into()).into_validation_status(); + assert_eq!(invalid.code(), tonic::Code::InvalidArgument); + assert_eq!(invalid.message(), "unknown capability"); + + let internal = BoxliteError::Internal("cannot read capability ceiling".into()) + .into_validation_status(); + assert_eq!(internal.code(), tonic::Code::Internal); + assert_eq!( + internal.message(), + "internal error: cannot read capability ceiling" + ); + } + /// Canonical `BoxliteError → (status, error_type, code)` table. /// /// Each row is asserted via [`BoxliteError::http`]. Adding a From 38169b285a643dbce4e7ee3ee52bde3c86e415ee Mon Sep 17 00:00:00 2001 From: tester Date: Mon, 27 Jul 2026 01:13:39 +0800 Subject: [PATCH 02/10] chore: keep generated Go OpenAPI spec unchanged --- apps/api-client-go/.openapi-generator-ignore | 4 ++ apps/api-client-go/.openapi-generator/FILES | 3 +- apps/api-client-go/api/openapi.yaml | 57 ------------------- .../model_runner_healthcheck_test.go | 14 +++-- apps/api-client-go/project.json | 3 +- 5 files changed, 14 insertions(+), 67 deletions(-) rename apps/api-client-go/{ => contracttest}/model_runner_healthcheck_test.go (82%) diff --git a/apps/api-client-go/.openapi-generator-ignore b/apps/api-client-go/.openapi-generator-ignore index da5f36200..e8d83c8b6 100644 --- a/apps/api-client-go/.openapi-generator-ignore +++ b/apps/api-client-go/.openapi-generator-ignore @@ -28,3 +28,7 @@ test/* .gitlab-ci.yml docs/* README.md + +# Deprecated snapshot. The NestJS export is authoritative; keep this file +# unchanged until it is removed from the repository. +api/openapi.yaml diff --git a/apps/api-client-go/.openapi-generator/FILES b/apps/api-client-go/.openapi-generator/FILES index 673fef20b..5fe7b3b13 100644 --- a/apps/api-client-go/.openapi-generator/FILES +++ b/apps/api-client-go/.openapi-generator/FILES @@ -1,5 +1,4 @@ .gitignore -api/openapi.yaml api_admin.go api_api_keys.go api_audit.go @@ -77,8 +76,8 @@ model_health_controller_check_200_response_info_value.go model_job.go model_job_status.go model_job_type.go -model_log_entry.go model_linux_capabilities.go +model_log_entry.go model_metric_data_point.go model_metric_series.go model_metrics_response.go diff --git a/apps/api-client-go/api/openapi.yaml b/apps/api-client-go/api/openapi.yaml index 62add57f1..e18d88137 100644 --- a/apps/api-client-go/api/openapi.yaml +++ b/apps/api-client-go/api/openapi.yaml @@ -6306,41 +6306,6 @@ components: - mountPath - volumeId type: object - LinuxCapabilities: - example: - add: - - SYS_ADMIN - drop: - - NET_RAW - properties: - add: - description: Linux capabilities added to the default container capability - set - items: - type: string - type: array - drop: - description: Linux capabilities removed from the container capability set - items: - type: string - type: array - required: - - add - - drop - type: object - BoxAdvancedOptions: - example: - capabilities: - add: - - SYS_ADMIN - drop: - - NET_RAW - properties: - capabilities: - $ref: "#/components/schemas/LinuxCapabilities" - required: - - capabilities - type: object Box: example: id: aB3cD4eF5gH6 @@ -6349,12 +6314,6 @@ components: user: boxlite env: NODE_ENV: production - advanced: - capabilities: - add: - - SYS_ADMIN - drop: - - NET_RAW labels: boxlite.io/public: "true" public: false @@ -6410,10 +6369,6 @@ components: example: NODE_ENV: production type: object - advanced: - allOf: - - $ref: "#/components/schemas/BoxAdvancedOptions" - description: Advanced box configuration labels: additionalProperties: type: string @@ -6523,7 +6478,6 @@ components: example: https://proxy.app.boxlite.io/toolbox type: string required: - - advanced - cpu - disk - env @@ -7295,8 +7249,6 @@ components: proxyUrl: http://proxy.boxlite.example.com:8080 apiUrl: http://api.boxlite.example.com:8080 appVersion: v0.0.0-dev - features: - - linux-capabilities-v2 properties: metrics: allOf: @@ -7323,13 +7275,6 @@ components: description: Runner app version example: v0.0.0-dev type: string - features: - description: Optional runner features used for rollout negotiation - example: - - linux-capabilities-v2 - items: - type: string - type: array required: - appVersion type: object @@ -7419,7 +7364,6 @@ components: description: The type of the job enum: - CREATE_BOX - - CREATE_BOX_WITH_CAPABILITIES_V2 - START_BOX - STOP_BOX - DESTROY_BOX @@ -7427,7 +7371,6 @@ components: - CREATE_BACKUP - PULL_ARTIFACT - RECOVER_BOX - - RECOVER_BOX_WITH_CAPABILITIES_V2 - INSPECT_ARTIFACT_IN_REGISTRY - REMOVE_ARTIFACT - UPDATE_BOX_NETWORK_SETTINGS diff --git a/apps/api-client-go/model_runner_healthcheck_test.go b/apps/api-client-go/contracttest/model_runner_healthcheck_test.go similarity index 82% rename from apps/api-client-go/model_runner_healthcheck_test.go rename to apps/api-client-go/contracttest/model_runner_healthcheck_test.go index 31b4b31f9..65146491f 100644 --- a/apps/api-client-go/model_runner_healthcheck_test.go +++ b/apps/api-client-go/contracttest/model_runner_healthcheck_test.go @@ -1,13 +1,15 @@ -package apiclient +package contracttest import ( "encoding/json" "reflect" "testing" + + apiclient "github.com/boxlite-ai/boxlite/libs/api-client-go" ) func TestRunnerHealthcheckCarriesAdvertisedFeatures(t *testing.T) { - healthcheck := NewRunnerHealthcheck("v1.0.0") + healthcheck := apiclient.NewRunnerHealthcheck("v1.0.0") healthcheck.SetFeatures([]string{"linux-capabilities-v2"}) payload, err := json.Marshal(healthcheck) @@ -25,7 +27,7 @@ func TestRunnerHealthcheckCarriesAdvertisedFeatures(t *testing.T) { } func TestRunnerHealthcheckDoesNotTreatFeaturesAsAdditional(t *testing.T) { - var healthcheck RunnerHealthcheck + var healthcheck apiclient.RunnerHealthcheck if err := json.Unmarshal([]byte(`{"appVersion":"v1.0.0","features":["linux-capabilities-v2"]}`), &healthcheck); err != nil { t.Fatalf("unmarshal healthcheck: %v", err) } @@ -36,13 +38,13 @@ func TestRunnerHealthcheckDoesNotTreatFeaturesAsAdditional(t *testing.T) { } func TestBoxDoesNotTreatCapabilitiesAsAdditional(t *testing.T) { - box := NewBox( + box := apiclient.NewBox( "box-1", "org-1", "cap-box", "boxlite", map[string]string{}, - *NewBoxAdvancedOptions(*NewLinuxCapabilities([]string{"SYS_ADMIN"}, []string{"NET_RAW"})), + *apiclient.NewBoxAdvancedOptions(*apiclient.NewLinuxCapabilities([]string{"SYS_ADMIN"}, []string{"NET_RAW"})), map[string]string{}, true, false, @@ -58,7 +60,7 @@ func TestBoxDoesNotTreatCapabilitiesAsAdditional(t *testing.T) { t.Fatalf("marshal box: %v", err) } - var decoded Box + var decoded apiclient.Box if err := json.Unmarshal(payload, &decoded); err != nil { t.Fatalf("unmarshal box: %v", err) } diff --git a/apps/api-client-go/project.json b/apps/api-client-go/project.json index b6abf19bf..0c3970533 100644 --- a/apps/api-client-go/project.json +++ b/apps/api-client-go/project.json @@ -26,8 +26,7 @@ "outputs": [ "{projectRoot}/*.go", "{projectRoot}/go.mod", - "{projectRoot}/go.sum", - "{projectRoot}/api/openapi.yaml" + "{projectRoot}/go.sum" ], "options": { "commands": [ From 8b8d674c96d97d9806d0c568375fa562a97fc8fd Mon Sep 17 00:00:00 2001 From: tester Date: Mon, 27 Jul 2026 01:43:25 +0800 Subject: [PATCH 03/10] refactor: remove unused capability proto reservations --- docs/architecture/container-capabilities.md | 9 +++------ src/shared/proto/boxlite/v1/service.proto | 6 ------ 2 files changed, 3 insertions(+), 12 deletions(-) diff --git a/docs/architecture/container-capabilities.md b/docs/architecture/container-capabilities.md index d0b04b391..05ad16fe6 100644 --- a/docs/architecture/container-capabilities.md +++ b/docs/architecture/container-capabilities.md @@ -70,12 +70,9 @@ every box response that omits `advanced.capabilities`. Legacy read routes stay available for older clients, but capability-aware clients deliberately trade old-server inspection compatibility for authoritative security metadata. -The structured host/guest protobuf reserves the prototype's flat field names -and tags 6/7, then carries `advanced` on tag 8. The nested message and the old -repeated strings share a length-delimited wire type, so reusing either tag -could let a mixed-version guest interpret an encoded message as a capability -name. The `-v2` feature token and capability-specific v2 job kinds similarly -keep queued flat prototype payloads from being reinterpreted as the nested +The structured host/guest protobuf carries the policy under an `advanced` +message. The `-v2` feature token and capability-specific v2 job kinds keep +mixed-version guests and queued jobs from silently ignoring the nested contract. Persistence has explicit downgrade barriers. Opening a local database migrates diff --git a/src/shared/proto/boxlite/v1/service.proto b/src/shared/proto/boxlite/v1/service.proto index 8b3ac205a..59dc0296d 100644 --- a/src/shared/proto/boxlite/v1/service.proto +++ b/src/shared/proto/boxlite/v1/service.proto @@ -311,12 +311,6 @@ message ContainerConfig { // ResizeTty sets the real one. bool tty = 5; - // Tags used by an early flat capability-policy prototype. They must not be - // reused because repeated strings and nested messages share the same wire - // type and could be silently misread by mixed-version peers. - reserved 6, 7; - reserved "cap_add", "cap_drop"; - // Expert-only container process options. ContainerAdvancedOptions advanced = 8; } From b6f873a1d1e9208582971cb2faea90a8b5849e11 Mon Sep 17 00:00:00 2001 From: tester Date: Mon, 27 Jul 2026 03:07:59 +0800 Subject: [PATCH 04/10] refactor: validate capability policy through sanitize --- src/boxlite/src/rest/runtime.rs | 7 ++-- src/boxlite/src/runtime/advanced_options.rs | 4 +-- src/boxlite/src/runtime/options.rs | 38 ++++++++++++++------- src/boxlite/src/runtime/rt_impl.rs | 10 ++---- 4 files changed, 32 insertions(+), 27 deletions(-) diff --git a/src/boxlite/src/rest/runtime.rs b/src/boxlite/src/rest/runtime.rs index adcc486dc..0bf09f1e4 100644 --- a/src/boxlite/src/rest/runtime.rs +++ b/src/boxlite/src/rest/runtime.rs @@ -68,7 +68,7 @@ impl RestRuntime { )); }; let box_name = resp.name.as_deref().unwrap_or(&resp.box_id); - options.ensure_capability_policy_matches(&advanced.capability_policy(), box_name)?; + options.sanitize_against(&advanced.capability_policy(), box_name)?; } let info = resp.to_box_info()?; let rest_box = Arc::new(RestBox::new(self.client.clone(), info)); @@ -147,10 +147,7 @@ impl RuntimeBackend for RestRuntime { .into(), )); }; - options.ensure_capability_policy_matches( - &advanced.capability_policy(), - box_name, - )?; + options.sanitize_against(&advanced.capability_policy(), box_name)?; let info = resp.to_box_info()?; let rest_box = Arc::new(RestBox::new(self.client.clone(), info)); return Ok((litebox_from_rest(rest_box), false)); diff --git a/src/boxlite/src/runtime/advanced_options.rs b/src/boxlite/src/runtime/advanced_options.rs index 4c03086c8..8dc6234c7 100644 --- a/src/boxlite/src/runtime/advanced_options.rs +++ b/src/boxlite/src/runtime/advanced_options.rs @@ -620,8 +620,8 @@ impl ContainerCapabilities { Err(boxlite_shared::errors::BoxliteError::InvalidArgument( format!( - "capability policy does not match existing box '{box_name}'; \ - get_or_create never changes an existing box's security policy" + "requested capability policy does not match the authoritative policy for box \ + '{box_name}'" ), )) } diff --git a/src/boxlite/src/runtime/options.rs b/src/boxlite/src/runtime/options.rs index 7765befc8..ca6b64026 100644 --- a/src/boxlite/src/runtime/options.rs +++ b/src/boxlite/src/runtime/options.rs @@ -578,15 +578,17 @@ impl BoxOptions { Ok(()) } - /// Validate the security policy before `get_or_create` adopts an existing box. - pub(crate) fn ensure_capability_policy_matches( + /// Sanitize options and verify the requested capability policy against an + /// authoritative policy returned by the local or remote backend. + pub(crate) fn sanitize_against( &self, - existing: &crate::runtime::advanced_options::ContainerCapabilities, + authoritative: &crate::runtime::advanced_options::ContainerCapabilities, box_name: &str, ) -> BoxliteResult<()> { + self.sanitize()?; self.advanced .capabilities - .ensure_matches(existing, box_name) + .ensure_matches(authoritative, box_name) } } @@ -940,11 +942,26 @@ mod tests { } #[test] - fn get_or_create_rejects_capability_policy_drift() { + fn sanitize_against_rejects_capability_policy_drift() { + let malformed = BoxOptions { + advanced: AdvancedBoxOptions { + capabilities: ContainerCapabilities { + add: vec!["NET-ADMIN".into()], + ..Default::default() + }, + ..Default::default() + }, + ..Default::default() + }; + let error = malformed + .sanitize_against(&malformed.advanced.capabilities, "malformed-policy") + .expect_err("contextual sanitization must reject malformed capability names"); + assert!(error.to_string().contains("NET-ADMIN")); + let baseline = BoxOptions::default(); assert!( baseline - .ensure_capability_policy_matches(&ContainerCapabilities::default(), "same-policy") + .sanitize_against(&ContainerCapabilities::default(), "same-policy") .is_ok() ); @@ -960,7 +977,7 @@ mod tests { }; assert!( spelling_variant - .ensure_capability_policy_matches( + .sanitize_against( &ContainerCapabilities { add: vec!["NET_ADMIN".into(), "CAP_SYS_ADMIN".into()], ..Default::default() @@ -981,15 +998,12 @@ mod tests { ..Default::default() }; assert!(matches!( - restricted.ensure_capability_policy_matches( - &ContainerCapabilities::default(), - "baseline-box" - ), + restricted.sanitize_against(&ContainerCapabilities::default(), "baseline-box"), Err(boxlite_shared::errors::BoxliteError::InvalidArgument(_)) )); assert!(matches!( - baseline.ensure_capability_policy_matches( + baseline.sanitize_against( &ContainerCapabilities { add: vec!["CAP_SYS_ADMIN".into()], ..Default::default() diff --git a/src/boxlite/src/runtime/rt_impl.rs b/src/boxlite/src/runtime/rt_impl.rs index 950263881..159dd03ad 100644 --- a/src/boxlite/src/runtime/rt_impl.rs +++ b/src/boxlite/src/runtime/rt_impl.rs @@ -391,10 +391,7 @@ impl RuntimeImpl { && let Some((config, state)) = self.box_manager.lookup_box(name)? { return if reuse_existing { - options.ensure_capability_policy_matches( - &config.options.advanced.capabilities, - name, - )?; + options.sanitize_against(&config.options.advanced.capabilities, name)?; let (box_impl, _) = self.get_or_create_box_impl(config, state); Ok((litebox_from_impl(box_impl), false)) } else { @@ -435,10 +432,7 @@ impl RuntimeImpl { && let Some(ref name) = name && let Some((config, state)) = self.box_manager.lookup_box(name)? { - options.ensure_capability_policy_matches( - &config.options.advanced.capabilities, - name, - )?; + options.sanitize_against(&config.options.advanced.capabilities, name)?; let (box_impl, _) = self.get_or_create_box_impl(config, state); return Ok((litebox_from_impl(box_impl), false)); } From 83ba690a19aababb51a77f5c39fb314e4961b57b Mon Sep 17 00:00:00 2001 From: tester Date: Mon, 27 Jul 2026 11:08:39 +0800 Subject: [PATCH 05/10] refactor: align option compatibility naming --- sdks/c/src/advanced_options.rs | 2 +- sdks/c/src/tests.rs | 4 +- src/boxlite/src/litebox/init/mod.rs | 2 +- src/boxlite/src/rest/runtime.rs | 119 ++++++++++++++++--- src/boxlite/src/runtime/advanced_options.rs | 10 +- src/boxlite/src/runtime/core.rs | 4 +- src/boxlite/src/runtime/import.rs | 2 +- src/boxlite/src/runtime/options.rs | 123 ++++++++++++-------- src/boxlite/src/runtime/rt_impl.rs | 39 ++++++- 9 files changed, 220 insertions(+), 85 deletions(-) diff --git a/sdks/c/src/advanced_options.rs b/sdks/c/src/advanced_options.rs index 3e93686da..28a3020ae 100644 --- a/sdks/c/src/advanced_options.rs +++ b/sdks/c/src/advanced_options.rs @@ -128,7 +128,7 @@ fn set_capability_list( } Err(()) => { // Keep the handle invalid if a caller ignores the return code. The - // subsequent BoxOptions::sanitize call then rejects the policy + // subsequent BoxOptions::validate call then rejects the policy // instead of silently falling back to the baseline. assign( &mut handle.options, diff --git a/sdks/c/src/tests.rs b/sdks/c/src/tests.rs index 63946def0..b5e6d6748 100644 --- a/sdks/c/src/tests.rs +++ b/sdks/c/src/tests.rs @@ -512,7 +512,7 @@ fn null_capability_element_cannot_weaken_policy() { boxlite_options_set_advanced(opts, advanced); (*opts) .options - .sanitize() + .validate() .expect_err("a null cap_drop element must fail closed"); boxlite_advanced_options_free(advanced); boxlite_options_free(opts); @@ -544,7 +544,7 @@ fn invalid_utf8_capability_cannot_weaken_policy() { boxlite_options_set_advanced(opts, advanced); (*opts) .options - .sanitize() + .validate() .expect_err("invalid UTF-8 in cap_add must fail closed"); boxlite_advanced_options_free(advanced); boxlite_options_free(opts); diff --git a/src/boxlite/src/litebox/init/mod.rs b/src/boxlite/src/litebox/init/mod.rs index 0f6113dc2..eb3fed4b4 100644 --- a/src/boxlite/src/litebox/init/mod.rs +++ b/src/boxlite/src/litebox/init/mod.rs @@ -172,7 +172,7 @@ impl BoxBuilder { ) -> BoxliteResult { // Get options reference from config (no reconstruction needed!) let options = &config.options; - options.sanitize()?; + options.validate()?; Ok(Self { runtime, diff --git a/src/boxlite/src/rest/runtime.rs b/src/boxlite/src/rest/runtime.rs index 0bf09f1e4..95f5328be 100644 --- a/src/boxlite/src/rest/runtime.rs +++ b/src/boxlite/src/rest/runtime.rs @@ -30,6 +30,17 @@ impl RestRuntime { Ok(Self { client }) } + fn compatible_box_info(options: &BoxOptions, response: &BoxResponse) -> BoxliteResult { + if response.advanced.is_none() { + return Err(BoxliteError::Unsupported( + "REST server did not return an authoritative Linux capability policy".into(), + )); + } + let info = response.to_box_info()?; + options.check_options_compatibility(&info)?; + Ok(info) + } + async fn create_with_contract( &self, options: BoxOptions, @@ -61,16 +72,11 @@ impl RestRuntime { "/boxes" }; let resp: BoxResponse = self.client.post(create_path, &req).await?; - if uses_strict_contract { - let Some(advanced) = &resp.advanced else { - return Err(BoxliteError::Unsupported( - "REST server did not return an authoritative Linux capability policy".into(), - )); - }; - let box_name = resp.name.as_deref().unwrap_or(&resp.box_id); - options.sanitize_against(&advanced.capability_policy(), box_name)?; - } - let info = resp.to_box_info()?; + let info = if uses_strict_contract { + Self::compatible_box_info(&options, &resp)? + } else { + resp.to_box_info()? + }; let rest_box = Arc::new(RestBox::new(self.client.clone(), info)); Ok(litebox_from_rest(rest_box)) } @@ -141,14 +147,7 @@ impl RuntimeBackend for RestRuntime { let path = format!("/boxes/{box_name}/strict"); match self.client.get::(&path).await { Ok(resp) => { - let Some(advanced) = &resp.advanced else { - return Err(BoxliteError::Unsupported( - "REST server did not return an authoritative Linux capability policy" - .into(), - )); - }; - options.sanitize_against(&advanced.capability_policy(), box_name)?; - let info = resp.to_box_info()?; + let info = Self::compatible_box_info(&options, &resp)?; let rest_box = Arc::new(RestBox::new(self.client.clone(), info)); return Ok((litebox_from_rest(rest_box), false)); } @@ -283,6 +282,36 @@ mod tests { use tokio::io::{AsyncReadExt, AsyncWriteExt}; use tokio::net::TcpListener; + async fn json_server(bodies: Vec<&'static str>) -> (u16, tokio::task::JoinHandle>) { + let listener = TcpListener::bind(("127.0.0.1", 0)).await.unwrap(); + let port = listener.local_addr().unwrap().port(); + let server = tokio::spawn(async move { + let mut requests = Vec::new(); + for body in bodies { + let (mut socket, _) = listener.accept().await.unwrap(); + let mut headers = Vec::new(); + while !headers.ends_with(b"\r\n\r\n") { + headers.push(socket.read_u8().await.unwrap()); + } + let request = String::from_utf8(headers).unwrap(); + requests.push(request.lines().next().unwrap().to_string()); + socket + .write_all( + format!( + "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}", + body.len(), + body + ) + .as_bytes(), + ) + .await + .unwrap(); + } + requests + }); + (port, server) + } + #[tokio::test] async fn test_import_box_requires_capability() { // Without a running server, import_box should fail with a connection error @@ -436,6 +465,38 @@ mod tests { ); } + #[tokio::test] + async fn strict_create_rejects_incompatible_options() { + let (port, server) = json_server(vec![ + r#"{"capabilities":{"linux_capabilities_enabled":true}}"#, + r#"{"box_id":"01HJK4TNRPQSXYZ8WM6NCVT9R5","name":null,"status":"configured","created_at":"2026-01-01T00:00:00Z","updated_at":"2026-01-01T00:00:00Z","pid":null,"image":"alpine:latest","cpus":2,"memory_mib":512,"advanced":{"capabilities":{"add":["SYS_ADMIN"],"drop":[]}},"labels":{}}"#, + ]) + .await; + let runtime = + RestRuntime::new(&BoxliteRestOptions::new(format!("http://127.0.0.1:{port}"))).unwrap(); + let result = RuntimeBackend::create( + &runtime, + BoxOptions { + advanced: crate::AdvancedBoxOptions { + capabilities: crate::ContainerCapabilities { + add: vec!["NET_ADMIN".into()], + ..Default::default() + }, + ..Default::default() + }, + ..Default::default() + }, + None, + ) + .await; + + assert!(matches!(result, Err(BoxliteError::InvalidArgument(_)))); + assert_eq!( + server.await.unwrap(), + ["GET /v1/config HTTP/1.1", "POST /v1/boxes/strict HTTP/1.1"] + ); + } + #[tokio::test] async fn strict_create_rejects_response_without_capability_policy() { let listener = TcpListener::bind(("127.0.0.1", 0)).await.unwrap(); @@ -529,6 +590,28 @@ mod tests { assert_eq!(server.await.unwrap(), "GET /v1/boxes/named/strict HTTP/1.1"); } + #[tokio::test] + async fn get_or_create_rejects_incompatible_existing_options() { + let (port, server) = json_server(vec![ + r#"{"box_id":"01HJK4TNRPQSXYZ8WM6NCVT9R5","name":"named","status":"configured","created_at":"2026-01-01T00:00:00Z","updated_at":"2026-01-01T00:00:00Z","pid":null,"image":"alpine:latest","cpus":2,"memory_mib":512,"advanced":{"capabilities":{"add":["SYS_ADMIN"],"drop":[]}},"labels":{}}"#, + ]) + .await; + let runtime = + RestRuntime::new(&BoxliteRestOptions::new(format!("http://127.0.0.1:{port}"))).unwrap(); + let result = RuntimeBackend::get_or_create( + &runtime, + BoxOptions::default(), + Some("named".to_string()), + ) + .await; + + assert!(matches!(result, Err(BoxliteError::InvalidArgument(_)))); + assert_eq!( + server.await.unwrap(), + ["GET /v1/boxes/named/strict HTTP/1.1"] + ); + } + #[tokio::test] async fn authoritative_inspection_rejects_responses_without_capability_policy() { let listener = TcpListener::bind(("127.0.0.1", 0)).await.unwrap(); diff --git a/src/boxlite/src/runtime/advanced_options.rs b/src/boxlite/src/runtime/advanced_options.rs index 8dc6234c7..ede43715b 100644 --- a/src/boxlite/src/runtime/advanced_options.rs +++ b/src/boxlite/src/runtime/advanced_options.rs @@ -589,10 +589,10 @@ impl ContainerCapabilities { validate_capability_names("advanced.capabilities.drop", &self.drop) } - /// Validate the policy before `get_or_create` adopts an existing box. - pub(crate) fn ensure_matches( + /// Check the requested policy against the policy reported for a box. + pub(crate) fn check_compatibility( &self, - existing: &Self, + actual: &Self, box_name: &str, ) -> boxlite_shared::errors::BoxliteResult<()> { let canonicalize = |capabilities: &[String]| { @@ -612,8 +612,8 @@ impl ContainerCapabilities { .collect::>() }; - if canonicalize(&self.add) == canonicalize(&existing.add) - && canonicalize(&self.drop) == canonicalize(&existing.drop) + if canonicalize(&self.add) == canonicalize(&actual.add) + && canonicalize(&self.drop) == canonicalize(&actual.drop) { return Ok(()); } diff --git a/src/boxlite/src/runtime/core.rs b/src/boxlite/src/runtime/core.rs index 755915704..f125baa62 100644 --- a/src/boxlite/src/runtime/core.rs +++ b/src/boxlite/src/runtime/core.rs @@ -280,7 +280,7 @@ impl BoxliteRuntime { ) -> BoxliteResult { // Reject incompatible option combinations at the create boundary (fail // here, not at start), uniformly for the local and REST backends. - options.sanitize()?; + options.validate()?; self.backend.create(options, name).await } @@ -295,7 +295,7 @@ impl BoxliteRuntime { options: BoxOptions, name: Option, ) -> BoxliteResult<(LiteBox, bool)> { - options.sanitize()?; + options.validate()?; self.backend.get_or_create(options, name).await } diff --git a/src/boxlite/src/runtime/import.rs b/src/boxlite/src/runtime/import.rs index 22e1c8f18..b370924c7 100644 --- a/src/boxlite/src/runtime/import.rs +++ b/src/boxlite/src/runtime/import.rs @@ -80,7 +80,7 @@ fn options_from_manifest(manifest: &ArchiveManifest) -> BoxliteResult 0 } - /// Sanitize and validate options. + /// Validate options before they enter a runtime backend. /// /// Validates option combinations: /// - effective remove-on-stop (`auto_delete>0`, or deprecated `auto_remove`) /// with `detach=true` is invalid /// - `advanced.isolate_mounts=true` is only supported on Linux /// - `advanced.capabilities` contains well-formed Linux capability names - pub fn sanitize(&self) -> BoxliteResult<()> { + pub fn validate(&self) -> BoxliteResult<()> { if self.removes_on_stop() && self.detach { return Err(boxlite_shared::errors::BoxliteError::Config( "remove-on-stop is incompatible with detach=true. Detached boxes should use \ @@ -578,17 +578,22 @@ impl BoxOptions { Ok(()) } - /// Sanitize options and verify the requested capability policy against an - /// authoritative policy returned by the local or remote backend. - pub(crate) fn sanitize_against( - &self, - authoritative: &crate::runtime::advanced_options::ContainerCapabilities, - box_name: &str, - ) -> BoxliteResult<()> { - self.sanitize()?; + /// Backward-compatible name for [`Self::validate`]. + /// + /// This method validates options without modifying them. + pub fn sanitize(&self) -> BoxliteResult<()> { + self.validate() + } + + /// Check requested options against the effective options reported for a box. + /// + /// This is the compatibility boundary for adopting or acknowledging a box. + /// Comparisons for additional immutable options belong here. + pub(crate) fn check_options_compatibility(&self, actual: &crate::BoxInfo) -> BoxliteResult<()> { + let box_name = actual.name.as_deref().unwrap_or_else(|| actual.id.as_str()); self.advanced .capabilities - .ensure_matches(authoritative, box_name) + .check_compatibility(&actual.advanced.capabilities, box_name) } } @@ -778,6 +783,31 @@ mod tests { ContainerCapabilities, SecurityOptions, SecurityOptionsBuilder, }; + fn box_info_with_capabilities( + box_name: &str, + capabilities: ContainerCapabilities, + ) -> crate::BoxInfo { + let now = chrono::Utc::now(); + crate::BoxInfo { + id: crate::runtime::id::BoxID::parse("test-box").unwrap(), + name: Some(box_name.to_string()), + status: crate::litebox::BoxStatus::Configured, + created_at: now, + last_updated: now, + pid: None, + image: "alpine:latest".to_string(), + cpus: 1, + memory_mib: 512, + advanced: crate::runtime::types::BoxAdvancedInfo { capabilities }, + labels: Default::default(), + auto_pause: 0, + auto_delete: 0, + auto_resume: true, + health_status: Default::default(), + exit_code: None, + } + } + #[test] #[allow(deprecated)] fn test_box_options_defaults() { @@ -849,7 +879,7 @@ mod tests { } #[test] - fn box_options_sanitize_accepts_valid_capability_names() { + fn box_options_validate_accepts_valid_capability_names() { let opts = BoxOptions { advanced: AdvancedBoxOptions { capabilities: ContainerCapabilities { @@ -861,12 +891,12 @@ mod tests { ..Default::default() }; - opts.sanitize() + opts.validate() .expect("Docker-style capability names should be accepted"); } #[test] - fn box_options_sanitize_accepts_future_capability_names() { + fn box_options_validate_accepts_future_capability_names() { let opts = BoxOptions { advanced: AdvancedBoxOptions { capabilities: ContainerCapabilities { @@ -878,12 +908,12 @@ mod tests { ..Default::default() }; - opts.sanitize() + opts.validate() .expect("the guest runtime, not the host SDK, owns the supported capability list"); } #[test] - fn box_options_sanitize_rejects_malformed_capability_names() { + fn box_options_validate_rejects_malformed_capability_names() { for opts in [ BoxOptions { advanced: AdvancedBoxOptions { @@ -927,7 +957,7 @@ mod tests { }, ] { let err = opts - .sanitize() + .validate() .expect_err("malformed capability should be rejected"); assert_eq!(err.http().0, 400); let err = err.to_string(); @@ -942,26 +972,14 @@ mod tests { } #[test] - fn sanitize_against_rejects_capability_policy_drift() { - let malformed = BoxOptions { - advanced: AdvancedBoxOptions { - capabilities: ContainerCapabilities { - add: vec!["NET-ADMIN".into()], - ..Default::default() - }, - ..Default::default() - }, - ..Default::default() - }; - let error = malformed - .sanitize_against(&malformed.advanced.capabilities, "malformed-policy") - .expect_err("contextual sanitization must reject malformed capability names"); - assert!(error.to_string().contains("NET-ADMIN")); - + fn check_options_compatibility_rejects_capability_policy_drift() { let baseline = BoxOptions::default(); assert!( baseline - .sanitize_against(&ContainerCapabilities::default(), "same-policy") + .check_options_compatibility(&box_info_with_capabilities( + "same-policy", + ContainerCapabilities::default(), + )) .is_ok() ); @@ -977,13 +995,13 @@ mod tests { }; assert!( spelling_variant - .sanitize_against( - &ContainerCapabilities { + .check_options_compatibility(&box_info_with_capabilities( + "same-policy", + ContainerCapabilities { add: vec!["NET_ADMIN".into(), "CAP_SYS_ADMIN".into()], ..Default::default() }, - "same-policy" - ) + )) .is_ok() ); @@ -998,18 +1016,21 @@ mod tests { ..Default::default() }; assert!(matches!( - restricted.sanitize_against(&ContainerCapabilities::default(), "baseline-box"), + restricted.check_options_compatibility(&box_info_with_capabilities( + "baseline-box", + ContainerCapabilities::default(), + )), Err(boxlite_shared::errors::BoxliteError::InvalidArgument(_)) )); assert!(matches!( - baseline.sanitize_against( - &ContainerCapabilities { + baseline.check_options_compatibility(&box_info_with_capabilities( + "privileged-box", + ContainerCapabilities { add: vec!["CAP_SYS_ADMIN".into()], ..Default::default() }, - "privileged-box" - ), + )), Err(boxlite_shared::errors::BoxliteError::InvalidArgument(_)) )); } @@ -1141,36 +1162,40 @@ mod tests { } #[test] - fn test_sanitize_remove_on_stop_detach_incompatible() { + fn test_validate_remove_on_stop_detach_incompatible() { let opts = BoxOptions { auto_delete: Some(1), detach: true, ..Default::default() }; - let err_msg = opts.sanitize().unwrap_err().to_string(); + let err_msg = opts.validate().unwrap_err().to_string(); assert!(err_msg.contains("incompatible")); } #[test] - fn test_sanitize_valid_combinations() { + fn test_validate_valid_combinations() { let remove = BoxOptions { auto_delete: Some(1), ..Default::default() }; - assert!(remove.sanitize().is_ok()); + assert!(remove.validate().is_ok()); let keep_detached = BoxOptions { auto_delete: Some(0), detach: true, ..Default::default() }; - assert!(keep_detached.sanitize().is_ok()); + assert!(keep_detached.validate().is_ok()); let keep_attached = BoxOptions { auto_delete: Some(0), ..Default::default() }; - assert!(keep_attached.sanitize().is_ok()); + assert!(keep_attached.validate().is_ok()); + + BoxOptions::default() + .sanitize() + .expect("the legacy sanitize name should continue to validate options"); } // ======================================================================== diff --git a/src/boxlite/src/runtime/rt_impl.rs b/src/boxlite/src/runtime/rt_impl.rs index 159dd03ad..caf6d4c85 100644 --- a/src/boxlite/src/runtime/rt_impl.rs +++ b/src/boxlite/src/runtime/rt_impl.rs @@ -391,9 +391,7 @@ impl RuntimeImpl { && let Some((config, state)) = self.box_manager.lookup_box(name)? { return if reuse_existing { - options.sanitize_against(&config.options.advanced.capabilities, name)?; - let (box_impl, _) = self.get_or_create_box_impl(config, state); - Ok((litebox_from_impl(box_impl), false)) + self.adopt_existing_box(&options, config, state) } else { Err(BoxliteError::InvalidArgument(format!( "box with name '{}' already exists", @@ -432,9 +430,7 @@ impl RuntimeImpl { && let Some(ref name) = name && let Some((config, state)) = self.box_manager.lookup_box(name)? { - options.sanitize_against(&config.options.advanced.capabilities, name)?; - let (box_impl, _) = self.get_or_create_box_impl(config, state); - return Ok((litebox_from_impl(box_impl), false)); + return self.adopt_existing_box(&options, config, state); } return Err(e); @@ -463,6 +459,18 @@ impl RuntimeImpl { Ok((litebox_from_impl(box_impl), true)) } + /// Adopt a box found either by the initial lookup or duplicate-create recovery. + fn adopt_existing_box( + self: &Arc, + requested: &BoxOptions, + config: BoxConfig, + state: BoxState, + ) -> BoxliteResult<(LiteBox, bool)> { + requested.check_options_compatibility(&BoxInfo::new(&config, &state))?; + let (box_impl, _) = self.get_or_create_box_impl(config, state); + Ok((litebox_from_impl(box_impl), false)) + } + /// Get a handle to an existing box by ID or name. /// /// Returns a LiteBox handle that can be used to operate on the box. @@ -1776,6 +1784,25 @@ mod tests { options.auto_delete = Some(3600); assert!(reject_local_lifecycle_policy(&options).is_ok()); } + + #[tokio::test] + async fn get_or_create_rejects_incompatible_existing_options() { + let (runtime, _dir) = create_test_runtime(); + let mut config = test_box_config_in_layout(false, &runtime); + config.name = Some("existing".to_string()); + config.options.advanced.capabilities.drop = vec!["NET_RAW".to_string()]; + runtime + .box_manager + .add_box(&config, &BoxState::new()) + .unwrap(); + + let result = runtime + .get_or_create(BoxOptions::default(), Some("existing".to_string())) + .await; + + assert!(matches!(result, Err(BoxliteError::InvalidArgument(_)))); + } + /// Create a RuntimeImpl with isolated temp directory. fn create_test_runtime() -> (SharedRuntimeImpl, TempDir) { let temp_dir = TempDir::new_in("/tmp").expect("Failed to create temp dir"); From 532fdea5bd324108887893dcb743505aafde08c5 Mon Sep 17 00:00:00 2001 From: tester Date: Mon, 27 Jul 2026 12:16:04 +0800 Subject: [PATCH 06/10] refactor: move box option compatibility to backends --- apps/api/src/box/services/box.service.spec.ts | 132 +++++++++++++++++- apps/api/src/box/services/box.service.ts | 79 +++++++++++ .../boxlite-rest/boxlite-box.controller.ts | 52 ++++++- .../boxlite-rest/boxlite-rest-routing.spec.ts | 29 ++++ .../src/boxlite-rest/dto/box-response.dto.ts | 9 ++ docs/architecture/container-capabilities.md | 6 +- openapi/box.openapi.yaml | 47 ++++++- src/boxlite/src/rest/runtime.rs | 115 +++++++-------- src/boxlite/src/rest/types.rs | 6 + src/boxlite/src/runtime/core.rs | 4 +- src/boxlite/src/runtime/options.rs | 100 ------------- src/boxlite/src/runtime/rt_impl.rs | 36 ++++- src/cli/src/commands/serve/handlers/boxes.rs | 37 ++++- src/cli/src/commands/serve/mod.rs | 4 + src/cli/src/commands/serve/types.rs | 6 + 15 files changed, 485 insertions(+), 177 deletions(-) diff --git a/apps/api/src/box/services/box.service.spec.ts b/apps/api/src/box/services/box.service.spec.ts index beac32535..87cdeb31b 100644 --- a/apps/api/src/box/services/box.service.spec.ts +++ b/apps/api/src/box/services/box.service.spec.ts @@ -3,7 +3,7 @@ * SPDX-License-Identifier: AGPL-3.0 */ -import { ForbiddenException } from '@nestjs/common' +import { ConflictException, ForbiddenException } from '@nestjs/common' import { BoxService } from './box.service' import { BoxState } from '../enums/box-state.enum' import { BoxDesiredState } from '../enums/box-desired-state.enum' @@ -361,3 +361,133 @@ describe('BoxService public defaults', () => { ) }) }) + +describe('BoxService getOrCreate option compatibility', () => { + function createGetOrCreateService(actualCapabilities: { add: string[]; drop: string[] }) { + const service = Object.create(BoxService.prototype) as BoxService + const existing = { + id: 'box-1', + name: 'named', + organizationId: 'org-1', + state: BoxState.STARTED, + advanced: { capabilities: actualCapabilities }, + } + ;(service as any).boxRepository = { + findOne: jest.fn().mockResolvedValue(existing), + } + service.toBoxDto = jest.fn().mockResolvedValue(existing as any) + service.create = jest.fn() + return service + } + + function createDuplicateRaceService(winner: any | null) { + const service = Object.create(BoxService.prototype) as BoxService + const conflict = new ConflictException('Box with name named already exists') + ;(service as any).boxRepository = { + findOne: jest.fn().mockResolvedValueOnce(null).mockResolvedValueOnce(winner), + } + service.toBoxDto = jest.fn().mockResolvedValue(winner) + service.create = jest.fn().mockRejectedValue(conflict) + return { service, conflict } + } + + it('rejects an incompatible existing capability policy', async () => { + const service = createGetOrCreateService({ add: ['SYS_ADMIN'], drop: [] }) + + await expect( + service.getOrCreate( + { + name: 'named', + advanced: { capabilities: { add: ['NET_ADMIN'], drop: [] } }, + }, + { id: 'org-1' } as any, + ), + ).rejects.toThrow('does not match') + expect(service.create).not.toHaveBeenCalled() + }) + + it('accepts equivalent normalized capability policies', async () => { + const service = createGetOrCreateService({ + add: ['NET_ADMIN', 'CAP_SYS_ADMIN'], + drop: [], + }) + + const result = await service.getOrCreate( + { + name: 'named', + advanced: { + capabilities: { add: ['sys_admin', 'CAP_NET_ADMIN'], drop: [] }, + }, + }, + { id: 'org-1' } as any, + ) + + expect(result.created).toBe(false) + expect(result.box).toMatchObject({ id: 'box-1' }) + expect(service.create).not.toHaveBeenCalled() + }) + + it('validates lifecycle options before returning an existing box', async () => { + const service = createGetOrCreateService({ add: [], drop: [] }) + + await expect( + service.getOrCreate( + { + name: 'named', + autoPause: 10, + autoDelete: 5, + }, + { id: 'org-1' } as any, + ), + ).rejects.toThrow('greater than auto-pause') + }) + + it('adopts a compatible winner after losing a duplicate-create race', async () => { + const winner = { + id: 'box-1', + name: 'named', + organizationId: 'org-1', + state: BoxState.STARTED, + advanced: { capabilities: { add: ['CAP_SYS_ADMIN'], drop: [] } }, + } + const { service } = createDuplicateRaceService(winner) + + const result = await service.getOrCreate( + { + name: 'named', + advanced: { capabilities: { add: ['sys_admin'], drop: [] } }, + }, + { id: 'org-1' } as any, + ) + + expect(result).toEqual({ box: winner, created: false }) + expect(service.create).toHaveBeenCalledTimes(1) + }) + + it('rejects an incompatible winner after losing a duplicate-create race', async () => { + const winner = { + id: 'box-1', + name: 'named', + organizationId: 'org-1', + state: BoxState.STARTED, + advanced: { capabilities: { add: ['SYS_ADMIN'], drop: [] } }, + } + const { service } = createDuplicateRaceService(winner) + + await expect( + service.getOrCreate( + { + name: 'named', + advanced: { capabilities: { add: ['NET_ADMIN'], drop: [] } }, + }, + { id: 'org-1' } as any, + ), + ).rejects.toThrow('does not match') + }) + + it('propagates the create conflict when no duplicate-race winner exists', async () => { + const { service, conflict } = createDuplicateRaceService(null) + + await expect(service.getOrCreate({ name: 'named' }, { id: 'org-1' } as any)).rejects.toBe(conflict) + }) +}) diff --git a/apps/api/src/box/services/box.service.ts b/apps/api/src/box/services/box.service.ts index e44eda7f6..622c719bd 100644 --- a/apps/api/src/box/services/box.service.ts +++ b/apps/api/src/box/services/box.service.ts @@ -92,6 +92,17 @@ const DEFAULT_BOX_DISK = 10 const DEFAULT_BOX_GPU = 0 const TERMINAL_PREVIEW_PORT = 22222 +function canonicalCapabilities(capabilities: readonly string[]): string[] { + return [ + ...new Set( + capabilities.map((capability) => { + const normalized = capability.toUpperCase() + return normalized === 'ALL' ? normalized : normalized.replace(/^CAP_/, '') + }), + ), + ].sort() +} + @Injectable() export class BoxService { private readonly logger = new Logger(BoxService.name) @@ -291,6 +302,74 @@ export class BoxService { } } + async getOrCreate( + createBoxDto: CreateBoxDto, + organization: Organization, + ): Promise<{ box: BoxDto; created: boolean }> { + this.resolveLifecyclePolicy({ + autoPause: createBoxDto.autoPause, + autoDelete: createBoxDto.autoDelete, + autoResume: createBoxDto.autoResume, + }) + + if (!createBoxDto.name) { + return { box: await this.create(createBoxDto, organization), created: true } + } + + const existing = await this.findReusableBoxByName(createBoxDto.name, organization.id) + if (existing) { + this.checkOptionsCompatibility(createBoxDto, existing) + return { box: await this.toBoxDto(existing), created: false } + } + + try { + return { box: await this.create(createBoxDto, organization), created: true } + } catch (error) { + if (!(error instanceof ConflictException)) { + throw error + } + + const winner = await this.findReusableBoxByName(createBoxDto.name, organization.id) + if (!winner) { + throw error + } + this.checkOptionsCompatibility(createBoxDto, winner) + return { box: await this.toBoxDto(winner), created: false } + } + } + + private async findReusableBoxByName(name: string, organizationId: string): Promise { + const box = await this.boxRepository.findOne({ + where: { + name, + organizationId, + state: Not(BoxState.DESTROYED), + }, + }) + if (box?.state === BoxState.ERROR && box.desiredState === BoxDesiredState.DESTROYED) { + return null + } + return box + } + + private checkOptionsCompatibility(requested: CreateBoxDto, actual: Box): void { + const requestedCapabilities = normalizeBoxAdvancedOptions(requested.advanced).capabilities + const actualCapabilities = normalizeBoxAdvancedOptions(actual.advanced).capabilities + const hasSameCapabilities = (['add', 'drop'] as const).every((field) => { + const requestedSet = canonicalCapabilities(requestedCapabilities[field]) + const actualSet = canonicalCapabilities(actualCapabilities[field]) + return ( + requestedSet.length === actualSet.length && requestedSet.every((value, index) => value === actualSet[index]) + ) + }) + + if (!hasSameCapabilities) { + throw new BadRequestError( + `requested capability policy does not match the authoritative policy for box '${actual.name || actual.id}'`, + ) + } + } + private async assignWarmPoolBox( warmPoolBox: Box, createBoxDto: CreateBoxDto, diff --git a/apps/api/src/boxlite-rest/boxlite-box.controller.ts b/apps/api/src/boxlite-rest/boxlite-box.controller.ts index a2c0f1bbd..09aa40176 100644 --- a/apps/api/src/boxlite-rest/boxlite-box.controller.ts +++ b/apps/api/src/boxlite-rest/boxlite-box.controller.ts @@ -32,7 +32,7 @@ import { BoxStateWaiterService } from '../box/services/box-state-waiter.service' import { Box } from '../box/entities/box.entity' import { BoxState } from '../box/enums/box-state.enum' import { BoxDesiredState } from '../box/enums/box-desired-state.enum' -import { BoxResponseDto, ListBoxesResponseDto } from './dto/box-response.dto' +import { BoxResponseDto, GetOrCreateBoxResponseDto, ListBoxesResponseDto } from './dto/box-response.dto' import { CreateBoxDto } from './dto/create-box.dto' import { boxToBoxResponse, createBoxToCreateBox } from './mappers/box-to-box.mapper' import { Audit, MASKED_AUDIT_VALUE, TypedRequest } from '../audit/decorators/audit.decorator' @@ -160,6 +160,56 @@ export class BoxliteBoxController { return this.createBoxWithOptions(authContext, dto) } + @Post('get-or-create/strict') + @HttpCode(200) + @UsePipes(new ValidationPipe({ transform: true, whitelist: true, forbidNonWhitelisted: true })) + @ApiResponse({ + status: 200, + description: 'Existing or newly created box', + type: GetOrCreateBoxResponseDto, + }) + @Audit({ + action: AuditAction.CREATE, + targetType: AuditTarget.BOX, + targetIdFromResult: (result: GetOrCreateBoxResponseDto) => result?.box_info?.box_id, + requestMetadata: { + body: (req: TypedRequest) => ({ + name: req.body?.name, + image: req.body?.image, + user: req.body?.user, + env: req.body?.env + ? Object.fromEntries(Object.keys(req.body?.env).map((key) => [key, MASKED_AUDIT_VALUE])) + : undefined, + cpus: req.body?.cpus, + memory_mib: req.body?.memory_mib, + disk_size_gb: req.body?.disk_size_gb, + working_dir: req.body?.working_dir, + entrypoint: req.body?.entrypoint, + cmd: req.body?.cmd, + advanced: req.body?.advanced, + detach: req.body?.detach, + auto_pause: req.body?.auto_pause, + auto_delete: req.body?.auto_delete, + auto_resume: req.body?.auto_resume, + }), + }, + }) + async getOrCreateBoxStrict( + @AuthContext() authContext: OrganizationAuthContext, + @Body() dto: CreateBoxDto, + ): Promise { + const createBoxDto = createBoxToCreateBox(dto) + const result = await this.boxService.getOrCreate(createBoxDto, authContext.organization) + let box = result.box + if (result.created && box.state !== BoxState.STARTED) { + box = await this.boxStateWaiter.waitForStarted(box.id, authContext.organizationId, 30) + } + return { + box_info: boxToBoxResponse(box), + created: result.created, + } + } + @Get(['', 'strict']) @ApiResponse({ status: 200, diff --git a/apps/api/src/boxlite-rest/boxlite-rest-routing.spec.ts b/apps/api/src/boxlite-rest/boxlite-rest-routing.spec.ts index bfb6daa4d..a23a8084a 100644 --- a/apps/api/src/boxlite-rest/boxlite-rest-routing.spec.ts +++ b/apps/api/src/boxlite-rest/boxlite-rest-routing.spec.ts @@ -39,6 +39,16 @@ describe('BoxLite REST routing', () => { findAllDeprecated: jest.fn().mockResolvedValue([]), toBoxDtos: jest.fn().mockResolvedValue([]), findOneByIdOrName: jest.fn().mockResolvedValue({ id: 'box-1' }), + getOrCreate: jest.fn().mockResolvedValue({ + box: { + id: 'box-1', + name: 'named', + state: BoxState.STARTED, + labels: {}, + advanced: { capabilities: { add: [], drop: [] } }, + }, + created: false, + }), toBoxDto: jest.fn().mockResolvedValue({ id: 'box-1', name: 'named', @@ -134,6 +144,25 @@ describe('BoxLite REST routing', () => { expect(await prefixed.json()).toEqual({ boxes: [] }) }) + it('delegates strict get-or-create compatibility to the box service', async () => { + await startRoutingTestApp() + + const response = await post('/api/v1/boxes/get-or-create/strict', { + name: 'named', + image: 'alpine:latest', + advanced: { capabilities: { add: [], drop: [] } }, + }) + + expect(response.status).toBe(200) + expect(await response.json()).toMatchObject({ + box_info: { + box_id: 'box-1', + advanced: { capabilities: { add: [], drop: [] } }, + }, + created: false, + }) + }) + it('rejects unknown fields at the strict create boundary', async () => { await startRoutingTestApp() diff --git a/apps/api/src/boxlite-rest/dto/box-response.dto.ts b/apps/api/src/boxlite-rest/dto/box-response.dto.ts index 207788816..b6bddfcfd 100644 --- a/apps/api/src/boxlite-rest/dto/box-response.dto.ts +++ b/apps/api/src/boxlite-rest/dto/box-response.dto.ts @@ -97,6 +97,15 @@ export class BoxResponseDto { auto_resume: boolean } +@ApiSchema({ name: 'GetOrCreateBoxResponse' }) +export class GetOrCreateBoxResponseDto { + @ApiProperty({ type: BoxResponseDto }) + box_info: BoxResponseDto + + @ApiProperty({ description: 'Whether this request created a new box' }) + created: boolean +} + @ApiSchema({ name: 'ListBoxesResponse' }) export class ListBoxesResponseDto { @ApiProperty({ type: [BoxResponseDto] }) diff --git a/docs/architecture/container-capabilities.md b/docs/architecture/container-capabilities.md index 05ad16fe6..2a6fe26e7 100644 --- a/docs/architecture/container-capabilities.md +++ b/docs/architecture/container-capabilities.md @@ -88,9 +88,9 @@ plane first, then capable runners and guests, and expose capability-aware clients only after that path is healthy. The new control plane safely rejects custom policies while only old runners are available. Ordinary create, get, and list operations from older clients remain compatible throughout the -rollout. Named `get_or_create` deliberately requires the strict policy-aware -read route even for an empty requested policy: otherwise an old API could omit a persisted -custom policy and make reuse appear safe. Drain a runner before downgrading or +rollout. Named `get_or_create` deliberately requires the strict server-side +operation even for an empty requested policy: otherwise an old API could omit a +persisted custom policy and make reuse appear safe. Drain a runner before downgrading or rolling it back: a previously positive advertisement cannot prove that the runner binary has not changed since its last heartbeat. Once a custom-policy box has been accepted, do not roll the control plane back to a build that diff --git a/openapi/box.openapi.yaml b/openapi/box.openapi.yaml index ec17f6360..d34e93b8a 100644 --- a/openapi/box.openapi.yaml +++ b/openapi/box.openapi.yaml @@ -310,6 +310,38 @@ paths: "422": $ref: "#/components/responses/UnprocessableEntityError" + /{prefix}/boxes/get-or-create/strict: + parameters: + - $ref: "#/components/parameters/prefix" + + post: + operationId: getOrCreateBoxStrict + summary: Get a named box or create it with backend compatibility checks + description: | + Atomically adopts an existing named box or creates it. The server + validates the requested options against an existing box before reuse; + clients do not infer compatibility from inspection metadata. + tags: [Boxes] + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/StrictCreateBoxRequest" + responses: + "200": + description: Existing or newly created box + content: + application/json: + schema: + $ref: "#/components/schemas/GetOrCreateBoxResponse" + "400": + $ref: "#/components/responses/BadRequestError" + "409": + $ref: "#/components/responses/ConflictError" + "422": + $ref: "#/components/responses/UnprocessableEntityError" + /{prefix}/boxes/{box_id}: parameters: - $ref: "#/components/parameters/prefix" @@ -1651,6 +1683,16 @@ components: default: true description: Whether the box automatically resumes when accessed after AutoPause + GetOrCreateBoxResponse: + type: object + required: [box_info, created] + properties: + box_info: + $ref: "#/components/schemas/Box" + created: + type: boolean + description: Whether this request created a new box + BoxStatus: type: string description: | @@ -1781,7 +1823,8 @@ components: - type: object description: | Fail-closed create request for security-sensitive options. This - schema is accepted only by `POST /boxes/strict`. + schema is accepted by the strict create and strict get-or-create + routes. properties: advanced: $ref: "#/components/schemas/CreateBoxAdvancedOptions" @@ -1789,7 +1832,7 @@ components: CreateBoxAdvancedOptions: type: object - description: Expert-only container options accepted by the strict create route. + description: Expert-only container options accepted by strict box creation routes. additionalProperties: false properties: capabilities: diff --git a/src/boxlite/src/rest/runtime.rs b/src/boxlite/src/rest/runtime.rs index 95f5328be..5ba4c3c25 100644 --- a/src/boxlite/src/rest/runtime.rs +++ b/src/boxlite/src/rest/runtime.rs @@ -13,8 +13,8 @@ use super::client::ApiClient; use super::litebox::RestBox; use super::options::BoxliteRestOptions; use super::types::{ - BoxResponse, CreateBoxRequest, CreateVolumeRequest, ListBoxesResponse, ListVolumesResponse, - RuntimeMetricsResponse, VolumeResponse, + BoxResponse, CreateBoxRequest, CreateVolumeRequest, GetOrCreateBoxResponse, ListBoxesResponse, + ListVolumesResponse, RuntimeMetricsResponse, VolumeResponse, }; use crate::runtime::auth::{AuthBackend, Principal}; use crate::runtime::volumes::VolumeBackend; @@ -30,22 +30,10 @@ impl RestRuntime { Ok(Self { client }) } - fn compatible_box_info(options: &BoxOptions, response: &BoxResponse) -> BoxliteResult { - if response.advanced.is_none() { - return Err(BoxliteError::Unsupported( - "REST server did not return an authoritative Linux capability policy".into(), - )); - } - let info = response.to_box_info()?; - options.check_options_compatibility(&info)?; - Ok(info) - } - async fn create_with_contract( &self, options: BoxOptions, name: Option, - force_strict: bool, ) -> BoxliteResult { // Validate only the caller's requested policy. An unset auto_pause means // "no auto-pause", so it must not borrow the server's default here. @@ -65,18 +53,14 @@ impl RestRuntime { // The strict route was introduced with capability policy support. An // older API instance returns 404 rather than accepting security-sensitive // fields it does not understand. - let uses_strict_contract = force_strict || has_capability_policy; + let uses_strict_contract = has_capability_policy; let create_path = if uses_strict_contract { "/boxes/strict" } else { "/boxes" }; let resp: BoxResponse = self.client.post(create_path, &req).await?; - let info = if uses_strict_contract { - Self::compatible_box_info(&options, &resp)? - } else { - resp.to_box_info()? - }; + let info = resp.to_box_info()?; let rest_box = Arc::new(RestBox::new(self.client.clone(), info)); Ok(litebox_from_rest(rest_box)) } @@ -132,7 +116,7 @@ fn litebox_from_rest(rest_box: Arc) -> LiteBox { #[async_trait::async_trait] impl RuntimeBackend for RestRuntime { async fn create(&self, options: BoxOptions, name: Option) -> BoxliteResult { - self.create_with_contract(options, name, false).await + self.create_with_contract(options, name).await } async fn get_or_create( @@ -140,26 +124,29 @@ impl RuntimeBackend for RestRuntime { options: BoxOptions, name: Option, ) -> BoxliteResult<(LiteBox, bool)> { - if let Some(ref box_name) = name { - // Named reuse must be policy-aware even when the requested policy is - // the baseline. The versioned route prevents an old API instance - // from omitting the fields and masquerading as an empty policy. - let path = format!("/boxes/{box_name}/strict"); - match self.client.get::(&path).await { - Ok(resp) => { - let info = Self::compatible_box_info(&options, &resp)?; - let rest_box = Arc::new(RestBox::new(self.client.clone(), info)); - return Ok((litebox_from_rest(rest_box), false)); - } - Err(BoxliteError::NotFound(_)) => {} - Err(error) => return Err(error), + if name.is_some() { + crate::runtime::types::BoxLifecyclePolicy { + auto_pause: options.auto_pause.unwrap_or(0), + auto_delete: options.auto_delete.unwrap_or(0), + auto_resume: options.auto_resume.unwrap_or(true), + } + .validate()?; + + if !options.advanced.capabilities.is_empty() { + self.client.require_linux_capabilities_enabled().await?; } - let litebox = self.create_with_contract(options, name, true).await?; - return Ok((litebox, true)); + let request = CreateBoxRequest::from_options(&options, name); + let response: GetOrCreateBoxResponse = self + .client + .post("/boxes/get-or-create/strict", &request) + .await?; + let info = response.box_info.to_box_info()?; + let rest_box = Arc::new(RestBox::new(self.client.clone(), info)); + return Ok((litebox_from_rest(rest_box), response.created)); } - let litebox = self.create_with_contract(options, name, false).await?; + let litebox = self.create_with_contract(options, name).await?; Ok((litebox, true)) } @@ -466,7 +453,7 @@ mod tests { } #[tokio::test] - async fn strict_create_rejects_incompatible_options() { + async fn strict_create_does_not_recheck_server_options() { let (port, server) = json_server(vec![ r#"{"capabilities":{"linux_capabilities_enabled":true}}"#, r#"{"box_id":"01HJK4TNRPQSXYZ8WM6NCVT9R5","name":null,"status":"configured","created_at":"2026-01-01T00:00:00Z","updated_at":"2026-01-01T00:00:00Z","pid":null,"image":"alpine:latest","cpus":2,"memory_mib":512,"advanced":{"capabilities":{"add":["SYS_ADMIN"],"drop":[]}},"labels":{}}"#, @@ -474,7 +461,7 @@ mod tests { .await; let runtime = RestRuntime::new(&BoxliteRestOptions::new(format!("http://127.0.0.1:{port}"))).unwrap(); - let result = RuntimeBackend::create( + RuntimeBackend::create( &runtime, BoxOptions { advanced: crate::AdvancedBoxOptions { @@ -488,9 +475,8 @@ mod tests { }, None, ) - .await; - - assert!(matches!(result, Err(BoxliteError::InvalidArgument(_)))); + .await + .unwrap(); assert_eq!( server.await.unwrap(), ["GET /v1/config HTTP/1.1", "POST /v1/boxes/strict HTTP/1.1"] @@ -498,7 +484,7 @@ mod tests { } #[tokio::test] - async fn strict_create_rejects_response_without_capability_policy() { + async fn strict_create_accepts_response_without_capability_policy() { let listener = TcpListener::bind(("127.0.0.1", 0)).await.unwrap(); let port = listener.local_addr().unwrap().port(); let server = tokio::spawn(async move { @@ -527,7 +513,7 @@ mod tests { let runtime = RestRuntime::new(&BoxliteRestOptions::new(format!("http://127.0.0.1:{port}"))).unwrap(); - let result = RuntimeBackend::create( + RuntimeBackend::create( &runtime, BoxOptions { advanced: crate::AdvancedBoxOptions { @@ -541,17 +527,13 @@ mod tests { }, None, ) - .await; - - assert!( - matches!(result, Err(BoxliteError::Unsupported(_))), - "a strict response without an authoritative policy must fail closed" - ); + .await + .unwrap(); server.await.unwrap(); } #[tokio::test] - async fn get_or_create_rejects_response_without_capability_policy() { + async fn get_or_create_accepts_response_without_capability_policy() { let listener = TcpListener::bind(("127.0.0.1", 0)).await.unwrap(); let port = listener.local_addr().unwrap().port(); let server = tokio::spawn(async move { @@ -561,7 +543,7 @@ mod tests { headers.push(socket.read_u8().await.unwrap()); } let request = String::from_utf8(headers).unwrap(); - let body = r#"{"box_id":"01HJK4TNRPQSXYZ8WM6NCVT9R5","name":"named","status":"configured","created_at":"2026-01-01T00:00:00Z","updated_at":"2026-01-01T00:00:00Z","pid":null,"image":"alpine:latest","cpus":2,"memory_mib":512,"labels":{}}"#; + let body = r#"{"box_info":{"box_id":"01HJK4TNRPQSXYZ8WM6NCVT9R5","name":"named","status":"configured","created_at":"2026-01-01T00:00:00Z","updated_at":"2026-01-01T00:00:00Z","pid":null,"image":"alpine:latest","cpus":2,"memory_mib":512,"labels":{}},"created":false}"#; socket .write_all( format!( @@ -578,37 +560,38 @@ mod tests { let runtime = RestRuntime::new(&BoxliteRestOptions::new(format!("http://127.0.0.1:{port}"))).unwrap(); - let result = + let (_, created) = RuntimeBackend::get_or_create(&runtime, BoxOptions::default(), Some("named".into())) - .await; - let error = match result { - Err(error) => error, - Ok(_) => panic!("an omitted policy must not masquerade as the baseline policy"), - }; + .await + .unwrap(); - assert!(matches!(error, BoxliteError::Unsupported(_))); - assert_eq!(server.await.unwrap(), "GET /v1/boxes/named/strict HTTP/1.1"); + assert!(!created); + assert_eq!( + server.await.unwrap(), + "POST /v1/boxes/get-or-create/strict HTTP/1.1" + ); } #[tokio::test] - async fn get_or_create_rejects_incompatible_existing_options() { + async fn get_or_create_does_not_recheck_server_options() { let (port, server) = json_server(vec![ - r#"{"box_id":"01HJK4TNRPQSXYZ8WM6NCVT9R5","name":"named","status":"configured","created_at":"2026-01-01T00:00:00Z","updated_at":"2026-01-01T00:00:00Z","pid":null,"image":"alpine:latest","cpus":2,"memory_mib":512,"advanced":{"capabilities":{"add":["SYS_ADMIN"],"drop":[]}},"labels":{}}"#, + r#"{"box_info":{"box_id":"01HJK4TNRPQSXYZ8WM6NCVT9R5","name":"named","status":"configured","created_at":"2026-01-01T00:00:00Z","updated_at":"2026-01-01T00:00:00Z","pid":null,"image":"alpine:latest","cpus":2,"memory_mib":512,"advanced":{"capabilities":{"add":["SYS_ADMIN"],"drop":[]}},"labels":{}},"created":false}"#, ]) .await; let runtime = RestRuntime::new(&BoxliteRestOptions::new(format!("http://127.0.0.1:{port}"))).unwrap(); - let result = RuntimeBackend::get_or_create( + let (_, created) = RuntimeBackend::get_or_create( &runtime, BoxOptions::default(), Some("named".to_string()), ) - .await; + .await + .unwrap(); - assert!(matches!(result, Err(BoxliteError::InvalidArgument(_)))); + assert!(!created); assert_eq!( server.await.unwrap(), - ["GET /v1/boxes/named/strict HTTP/1.1"] + ["POST /v1/boxes/get-or-create/strict HTTP/1.1"] ); } diff --git a/src/boxlite/src/rest/types.rs b/src/boxlite/src/rest/types.rs index 5b2c48354..b12356304 100644 --- a/src/boxlite/src/rest/types.rs +++ b/src/boxlite/src/rest/types.rs @@ -272,6 +272,12 @@ pub(crate) struct BoxResponse { pub auto_resume: bool, } +#[derive(Debug, Deserialize)] +pub(crate) struct GetOrCreateBoxResponse { + pub box_info: BoxResponse, + pub created: bool, +} + impl BoxResponse { pub(crate) fn to_authoritative_box_info( &self, diff --git a/src/boxlite/src/runtime/core.rs b/src/boxlite/src/runtime/core.rs index f125baa62..d4d06c979 100644 --- a/src/boxlite/src/runtime/core.rs +++ b/src/boxlite/src/runtime/core.rs @@ -288,8 +288,8 @@ impl BoxliteRuntime { /// /// Returns `(LiteBox, true)` if a new box was created, or `(LiteBox, false)` /// if an existing box with the given name was found. When an existing box is - /// returned, general options are ignored, but its capability policy must - /// match exactly so reuse cannot silently weaken or elevate privileges. + /// returned, each backend decides which requested options must be + /// compatible with the existing box. pub async fn get_or_create( &self, options: BoxOptions, diff --git a/src/boxlite/src/runtime/options.rs b/src/boxlite/src/runtime/options.rs index 854d753f7..6e6106461 100644 --- a/src/boxlite/src/runtime/options.rs +++ b/src/boxlite/src/runtime/options.rs @@ -584,17 +584,6 @@ impl BoxOptions { pub fn sanitize(&self) -> BoxliteResult<()> { self.validate() } - - /// Check requested options against the effective options reported for a box. - /// - /// This is the compatibility boundary for adopting or acknowledging a box. - /// Comparisons for additional immutable options belong here. - pub(crate) fn check_options_compatibility(&self, actual: &crate::BoxInfo) -> BoxliteResult<()> { - let box_name = actual.name.as_deref().unwrap_or_else(|| actual.id.as_str()); - self.advanced - .capabilities - .check_compatibility(&actual.advanced.capabilities, box_name) - } } /// How to populate the box root filesystem. @@ -783,31 +772,6 @@ mod tests { ContainerCapabilities, SecurityOptions, SecurityOptionsBuilder, }; - fn box_info_with_capabilities( - box_name: &str, - capabilities: ContainerCapabilities, - ) -> crate::BoxInfo { - let now = chrono::Utc::now(); - crate::BoxInfo { - id: crate::runtime::id::BoxID::parse("test-box").unwrap(), - name: Some(box_name.to_string()), - status: crate::litebox::BoxStatus::Configured, - created_at: now, - last_updated: now, - pid: None, - image: "alpine:latest".to_string(), - cpus: 1, - memory_mib: 512, - advanced: crate::runtime::types::BoxAdvancedInfo { capabilities }, - labels: Default::default(), - auto_pause: 0, - auto_delete: 0, - auto_resume: true, - health_status: Default::default(), - exit_code: None, - } - } - #[test] #[allow(deprecated)] fn test_box_options_defaults() { @@ -971,70 +935,6 @@ mod tests { } } - #[test] - fn check_options_compatibility_rejects_capability_policy_drift() { - let baseline = BoxOptions::default(); - assert!( - baseline - .check_options_compatibility(&box_info_with_capabilities( - "same-policy", - ContainerCapabilities::default(), - )) - .is_ok() - ); - - let spelling_variant = BoxOptions { - advanced: AdvancedBoxOptions { - capabilities: ContainerCapabilities { - add: vec!["sys_admin".into(), "CAP_NET_ADMIN".into()], - ..Default::default() - }, - ..Default::default() - }, - ..Default::default() - }; - assert!( - spelling_variant - .check_options_compatibility(&box_info_with_capabilities( - "same-policy", - ContainerCapabilities { - add: vec!["NET_ADMIN".into(), "CAP_SYS_ADMIN".into()], - ..Default::default() - }, - )) - .is_ok() - ); - - let restricted = BoxOptions { - advanced: AdvancedBoxOptions { - capabilities: ContainerCapabilities { - drop: vec!["ALL".into()], - ..Default::default() - }, - ..Default::default() - }, - ..Default::default() - }; - assert!(matches!( - restricted.check_options_compatibility(&box_info_with_capabilities( - "baseline-box", - ContainerCapabilities::default(), - )), - Err(boxlite_shared::errors::BoxliteError::InvalidArgument(_)) - )); - - assert!(matches!( - baseline.check_options_compatibility(&box_info_with_capabilities( - "privileged-box", - ContainerCapabilities { - add: vec!["CAP_SYS_ADMIN".into()], - ..Default::default() - }, - )), - Err(boxlite_shared::errors::BoxliteError::InvalidArgument(_)) - )); - } - #[test] #[allow(deprecated)] fn explicit_auto_delete_takes_precedence_over_auto_remove() { diff --git a/src/boxlite/src/runtime/rt_impl.rs b/src/boxlite/src/runtime/rt_impl.rs index caf6d4c85..e30faadc9 100644 --- a/src/boxlite/src/runtime/rt_impl.rs +++ b/src/boxlite/src/runtime/rt_impl.rs @@ -466,11 +466,26 @@ impl RuntimeImpl { config: BoxConfig, state: BoxState, ) -> BoxliteResult<(LiteBox, bool)> { - requested.check_options_compatibility(&BoxInfo::new(&config, &state))?; + Self::check_options_compatibility(requested, &config)?; let (box_impl, _) = self.get_or_create_box_impl(config, state); Ok((litebox_from_impl(box_impl), false)) } + /// Check requested options before adopting an existing local box. + /// + /// Comparisons for additional immutable options belong here so each + /// runtime backend owns its reuse policy. + fn check_options_compatibility( + requested: &BoxOptions, + actual: &BoxConfig, + ) -> BoxliteResult<()> { + let box_name = actual.name.as_deref().unwrap_or_else(|| actual.id.as_str()); + requested + .advanced + .capabilities + .check_compatibility(&actual.options.advanced.capabilities, box_name) + } + /// Get a handle to an existing box by ID or name. /// /// Returns a LiteBox handle that can be used to operate on the box. @@ -1785,6 +1800,25 @@ mod tests { assert!(reject_local_lifecycle_policy(&options).is_ok()); } + #[test] + fn options_compatibility_normalizes_capability_names() { + let mut actual = test_box_config(false); + actual.options.advanced.capabilities.add = + vec!["NET_ADMIN".to_string(), "CAP_SYS_ADMIN".to_string()]; + let requested = BoxOptions { + advanced: crate::AdvancedBoxOptions { + capabilities: crate::ContainerCapabilities { + add: vec!["sys_admin".to_string(), "CAP_NET_ADMIN".to_string()], + ..Default::default() + }, + ..Default::default() + }, + ..Default::default() + }; + + assert!(RuntimeImpl::check_options_compatibility(&requested, &actual).is_ok()); + } + #[tokio::test] async fn get_or_create_rejects_incompatible_existing_options() { let (runtime, _dir) = create_test_runtime(); diff --git a/src/cli/src/commands/serve/handlers/boxes.rs b/src/cli/src/commands/serve/handlers/boxes.rs index ae0036041..11b63d39a 100644 --- a/src/cli/src/commands/serve/handlers/boxes.rs +++ b/src/cli/src/commands/serve/handlers/boxes.rs @@ -7,7 +7,9 @@ use axum::extract::{Path, Query, State}; use axum::http::StatusCode; use axum::response::{IntoResponse, Response}; -use super::super::types::{CreateBoxRequest, ListBoxesResponse, RemoveQuery}; +use super::super::types::{ + CreateBoxRequest, GetOrCreateBoxResponse, ListBoxesResponse, RemoveQuery, +}; use super::super::{ AppState, box_info_to_response, build_box_options, error_from_boxlite, error_response, get_or_fetch_box, @@ -35,6 +37,39 @@ pub(in crate::commands::serve) async fn create_box_legacy( create_box_inner(state, req).await } +pub(in crate::commands::serve) async fn get_or_create_box( + State(state): State>, + Json(req): Json, +) -> Response { + let name = req.name.clone(); + let options = match build_box_options(&req) { + Ok(options) => options, + Err(error) => { + return error_response( + StatusCode::BAD_REQUEST, + error.to_string(), + "InvalidArgumentError", + "invalid_argument", + ); + } + }; + + let (litebox, created) = match state.runtime.get_or_create(options, name).await { + Ok(result) => result, + Err(error) => return error_from_boxlite(&error), + }; + + let info = litebox.info(); + let box_id = info.id.to_string(); + let response = GetOrCreateBoxResponse { + box_info: box_info_to_response(&info), + created, + }; + state.boxes.write().await.insert(box_id, Arc::new(litebox)); + + (StatusCode::OK, Json(response)).into_response() +} + async fn create_box_inner(state: Arc, req: CreateBoxRequest) -> Response { let name = req.name.clone(); let options = match build_box_options(&req) { diff --git a/src/cli/src/commands/serve/mod.rs b/src/cli/src/commands/serve/mod.rs index 92da59327..c45184fbf 100644 --- a/src/cli/src/commands/serve/mod.rs +++ b/src/cli/src/commands/serve/mod.rs @@ -1095,6 +1095,10 @@ fn build_router(state: Arc) -> Router { ) // Box CRUD (import first — static path before param path) .route("/v1/boxes/import", post(advanced::import_box)) + .route( + "/v1/boxes/get-or-create/strict", + post(boxes::get_or_create_box), + ) .route( "/v1/boxes/strict", post(boxes::create_box).get(boxes::list_boxes), diff --git a/src/cli/src/commands/serve/types.rs b/src/cli/src/commands/serve/types.rs index 15d55e263..f3c003ad4 100644 --- a/src/cli/src/commands/serve/types.rs +++ b/src/cli/src/commands/serve/types.rs @@ -127,6 +127,12 @@ pub(super) struct BoxResponse { pub exit_code: Option, } +#[derive(Serialize)] +pub(super) struct GetOrCreateBoxResponse { + pub box_info: BoxResponse, + pub created: bool, +} + #[derive(Serialize)] pub(super) struct BoxAdvancedResponse { pub capabilities: ContainerCapabilitiesResponse, From e1a5d88a5e4efe140dda5fda2fac86250b5ad779 Mon Sep 17 00:00:00 2001 From: tester Date: Mon, 27 Jul 2026 13:46:39 +0800 Subject: [PATCH 07/10] refactor: remove box capability inspection metadata BREAKING CHANGE: Box capability metadata is no longer exposed by inspection APIs. --- apps/api-client-go/.openapi-generator/FILES | 2 - .../model_linux_capabilities_test.go | 71 ---- .../model_runner_healthcheck_test.go | 35 -- apps/api-client-go/model_box.go | 31 +- .../model_box_advanced_options.go | 123 ------ .../api-client-go/model_linux_capabilities.go | 150 ------- .../src/box/dto/box-advanced-options.dto.ts | 30 -- apps/api/src/box/dto/box.dto.spec.ts | 3 +- apps/api/src/box/dto/box.dto.ts | 10 - .../boxlite-rest/boxlite-box.controller.ts | 4 +- .../boxlite-rest/boxlite-rest-routing.spec.ts | 42 +- .../src/boxlite-rest/dto/box-response.dto.ts | 8 - .../mappers/box-to-box.mapper.spec.ts | 6 +- .../boxlite-rest/mappers/box-to-box.mapper.ts | 6 +- apps/dashboard/src/mocks/fixtures.ts | 1 - apps/hack/go-client/postprocess.sh | 13 - apps/libs/api-client/src/docs/Box.md | 2 - .../api-client/src/docs/BoxAdvancedOptions.md | 9 - .../api-client/src/docs/LinuxCapabilities.md | 10 - .../src/models/box-advanced-options.ts | 15 - apps/libs/api-client/src/models/box.ts | 7 - apps/libs/api-client/src/models/index.ts | 2 - .../src/models/linux-capabilities.ts | 16 - docs/architecture/container-capabilities.md | 11 +- docs/reference/c/README.md | 12 - docs/reference/nodejs/README.md | 3 - docs/reference/python/README.md | 3 - openapi/box.openapi.yaml | 81 +--- openapi/reference-server/server.py | 10 - sdks/c/README.md | 8 - sdks/c/include/boxlite.h | 48 --- sdks/c/src/event_queue.rs | 24 +- sdks/c/src/info.rs | 373 +----------------- sdks/c/src/lib.rs | 4 - sdks/c/src/runtime.rs | 10 - sdks/go/README.md | 3 - sdks/go/bridge.c | 8 +- sdks/go/bridge.h | 4 +- sdks/go/bridge_callback.go | 24 +- sdks/go/info.go | 74 ++-- sdks/node/README.md | 1 - sdks/node/lib/native-contracts.ts | 10 - sdks/node/src/info.rs | 26 -- sdks/node/src/lib.rs | 5 +- sdks/python/README.md | 3 - sdks/python/boxlite/__init__.py | 4 +- sdks/python/src/info.rs | 40 -- sdks/python/src/lib.rs | 3 +- src/boxlite/src/lib.rs | 4 +- src/boxlite/src/rest/runtime.rs | 73 +--- src/boxlite/src/rest/types.rs | 72 ---- src/boxlite/src/runtime/types.rs | 20 - src/cli/src/commands/inspect.rs | 39 +- src/cli/src/commands/serve/mod.rs | 9 +- src/cli/src/commands/serve/types.rs | 12 - 55 files changed, 81 insertions(+), 1536 deletions(-) delete mode 100644 apps/api-client-go/contracttest/model_linux_capabilities_test.go delete mode 100644 apps/api-client-go/model_box_advanced_options.go delete mode 100644 apps/api-client-go/model_linux_capabilities.go delete mode 100644 apps/api/src/box/dto/box-advanced-options.dto.ts delete mode 100644 apps/libs/api-client/src/docs/BoxAdvancedOptions.md delete mode 100644 apps/libs/api-client/src/docs/LinuxCapabilities.md delete mode 100644 apps/libs/api-client/src/models/box-advanced-options.ts delete mode 100644 apps/libs/api-client/src/models/linux-capabilities.ts diff --git a/apps/api-client-go/.openapi-generator/FILES b/apps/api-client-go/.openapi-generator/FILES index 5fe7b3b13..ab88c087e 100644 --- a/apps/api-client-go/.openapi-generator/FILES +++ b/apps/api-client-go/.openapi-generator/FILES @@ -53,7 +53,6 @@ model_api_key_list.go model_api_key_response.go model_audit_log.go model_box.go -model_box_advanced_options.go model_box_class.go model_box_desired_state.go model_box_labels.go @@ -76,7 +75,6 @@ model_health_controller_check_200_response_info_value.go model_job.go model_job_status.go model_job_type.go -model_linux_capabilities.go model_log_entry.go model_metric_data_point.go model_metric_series.go diff --git a/apps/api-client-go/contracttest/model_linux_capabilities_test.go b/apps/api-client-go/contracttest/model_linux_capabilities_test.go deleted file mode 100644 index dfc7de51d..000000000 --- a/apps/api-client-go/contracttest/model_linux_capabilities_test.go +++ /dev/null @@ -1,71 +0,0 @@ -package contracttest - -import ( - "encoding/json" - "strings" - "testing" - - apiclient "github.com/boxlite-ai/boxlite/libs/api-client-go" -) - -func TestCapabilityModelsRejectNullRequiredValues(t *testing.T) { - tests := []struct { - name string - payload string - target func() any - wantErrorProperty string - }{ - { - name: "add", - payload: `{"add":null,"drop":[]}`, - target: func() any { return &apiclient.LinuxCapabilities{} }, - wantErrorProperty: "add", - }, - { - name: "drop", - payload: `{"add":[],"drop":null}`, - target: func() any { return &apiclient.LinuxCapabilities{} }, - wantErrorProperty: "drop", - }, - { - name: "capabilities", - payload: `{"capabilities":null}`, - target: func() any { return &apiclient.BoxAdvancedOptions{} }, - wantErrorProperty: "add", - }, - { - name: "advanced", - payload: `{ - "id":"box-1", - "organizationId":"org-1", - "name":"box", - "user":"boxlite", - "env":{}, - "advanced":null, - "labels":{}, - "public":false, - "networkBlockAll":false, - "target":"local", - "cpu":1, - "gpu":0, - "memory":1, - "disk":10, - "toolboxProxyUrl":"" - }`, - target: func() any { return &apiclient.Box{} }, - wantErrorProperty: "capabilities", - }, - } - - for _, test := range tests { - t.Run(test.name, func(t *testing.T) { - err := json.Unmarshal([]byte(test.payload), test.target()) - if err == nil { - t.Fatalf("expected %s to reject null", test.name) - } - if !strings.Contains(err.Error(), "required property "+test.wantErrorProperty) { - t.Fatalf("unexpected error for %s: %v", test.name, err) - } - }) - } -} diff --git a/apps/api-client-go/contracttest/model_runner_healthcheck_test.go b/apps/api-client-go/contracttest/model_runner_healthcheck_test.go index 65146491f..aeed138d3 100644 --- a/apps/api-client-go/contracttest/model_runner_healthcheck_test.go +++ b/apps/api-client-go/contracttest/model_runner_healthcheck_test.go @@ -36,38 +36,3 @@ func TestRunnerHealthcheckDoesNotTreatFeaturesAsAdditional(t *testing.T) { t.Fatal("known features field must not remain in AdditionalProperties") } } - -func TestBoxDoesNotTreatCapabilitiesAsAdditional(t *testing.T) { - box := apiclient.NewBox( - "box-1", - "org-1", - "cap-box", - "boxlite", - map[string]string{}, - *apiclient.NewBoxAdvancedOptions(*apiclient.NewLinuxCapabilities([]string{"SYS_ADMIN"}, []string{"NET_RAW"})), - map[string]string{}, - true, - false, - "local", - 1, - 0, - 1, - 10, - "https://example.test/toolbox", - ) - payload, err := json.Marshal(box) - if err != nil { - t.Fatalf("marshal box: %v", err) - } - - var decoded apiclient.Box - if err := json.Unmarshal(payload, &decoded); err != nil { - t.Fatalf("unmarshal box: %v", err) - } - if _, exists := decoded.AdditionalProperties["advanced"]; exists { - t.Fatal("known advanced field must not remain in AdditionalProperties") - } - if !reflect.DeepEqual(decoded.Advanced.Capabilities.Add, []string{"SYS_ADMIN"}) { - t.Fatalf("advanced capabilities lost during round trip: %s", payload) - } -} diff --git a/apps/api-client-go/model_box.go b/apps/api-client-go/model_box.go index da1d6bf79..fc93ec2a5 100644 --- a/apps/api-client-go/model_box.go +++ b/apps/api-client-go/model_box.go @@ -31,8 +31,6 @@ type Box struct { User string `json:"user"` // Environment variables for the box Env map[string]string `json:"env"` - // Advanced box configuration - Advanced BoxAdvancedOptions `json:"advanced"` // Labels for the box Labels map[string]string `json:"labels"` // Whether the box http preview is public @@ -91,14 +89,13 @@ type _Box Box // 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 NewBox(id string, organizationId string, name string, user string, env map[string]string, advanced BoxAdvancedOptions, labels map[string]string, public bool, networkBlockAll bool, target string, cpu float32, gpu float32, memory float32, disk float32, toolboxProxyUrl string) *Box { +func NewBox(id string, organizationId string, name string, user string, env map[string]string, labels map[string]string, public bool, networkBlockAll bool, target string, cpu float32, gpu float32, memory float32, disk float32, toolboxProxyUrl string) *Box { this := Box{} this.Id = id this.OrganizationId = organizationId this.Name = name this.User = user this.Env = env - this.Advanced = advanced this.Labels = labels this.Public = public this.NetworkBlockAll = networkBlockAll @@ -239,29 +236,6 @@ func (o *Box) SetEnv(v map[string]string) { o.Env = v } -// GetAdvanced returns the Advanced field value. -func (o *Box) GetAdvanced() BoxAdvancedOptions { - if o == nil { - var ret BoxAdvancedOptions - return ret - } - return o.Advanced -} - -// GetAdvancedOk returns a tuple with the Advanced field value -// and a boolean to check if the value has been set. -func (o *Box) GetAdvancedOk() (*BoxAdvancedOptions, bool) { - if o == nil { - return nil, false - } - return &o.Advanced, true -} - -// SetAdvanced sets field value. -func (o *Box) SetAdvanced(v BoxAdvancedOptions) { - o.Advanced = v -} - // GetLabels returns the Labels field value func (o *Box) GetLabels() map[string]string { if o == nil { @@ -976,7 +950,6 @@ func (o Box) ToMap() (map[string]interface{}, error) { toSerialize["name"] = o.Name toSerialize["user"] = o.User toSerialize["env"] = o.Env - toSerialize["advanced"] = o.Advanced toSerialize["labels"] = o.Labels toSerialize["public"] = o.Public toSerialize["networkBlockAll"] = o.NetworkBlockAll @@ -1049,7 +1022,6 @@ func (o *Box) UnmarshalJSON(data []byte) (err error) { "name", "user", "env", - "advanced", "labels", "public", "networkBlockAll", @@ -1093,7 +1065,6 @@ func (o *Box) UnmarshalJSON(data []byte) (err error) { delete(additionalProperties, "name") delete(additionalProperties, "user") delete(additionalProperties, "env") - delete(additionalProperties, "advanced") delete(additionalProperties, "labels") delete(additionalProperties, "public") delete(additionalProperties, "networkBlockAll") diff --git a/apps/api-client-go/model_box_advanced_options.go b/apps/api-client-go/model_box_advanced_options.go deleted file mode 100644 index 4202ef073..000000000 --- a/apps/api-client-go/model_box_advanced_options.go +++ /dev/null @@ -1,123 +0,0 @@ -/* -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" -) - -var _ MappedNullable = &BoxAdvancedOptions{} - -// BoxAdvancedOptions contains advanced box configuration. -type BoxAdvancedOptions struct { - Capabilities LinuxCapabilities `json:"capabilities"` - AdditionalProperties map[string]interface{} -} - -type _BoxAdvancedOptions BoxAdvancedOptions - -func NewBoxAdvancedOptions(capabilities LinuxCapabilities) *BoxAdvancedOptions { - this := BoxAdvancedOptions{} - this.Capabilities = capabilities - return &this -} - -func NewBoxAdvancedOptionsWithDefaults() *BoxAdvancedOptions { - this := BoxAdvancedOptions{} - return &this -} - -func (o *BoxAdvancedOptions) GetCapabilities() LinuxCapabilities { - if o == nil { - var ret LinuxCapabilities - return ret - } - return o.Capabilities -} - -func (o *BoxAdvancedOptions) GetCapabilitiesOk() (*LinuxCapabilities, bool) { - if o == nil { - return nil, false - } - return &o.Capabilities, true -} - -func (o *BoxAdvancedOptions) SetCapabilities(v LinuxCapabilities) { - o.Capabilities = v -} - -func (o BoxAdvancedOptions) MarshalJSON() ([]byte, error) { - toSerialize, err := o.ToMap() - if err != nil { - return []byte{}, err - } - return json.Marshal(toSerialize) -} - -func (o BoxAdvancedOptions) ToMap() (map[string]interface{}, error) { - toSerialize := map[string]interface{}{ - "capabilities": o.Capabilities, - } - for key, value := range o.AdditionalProperties { - toSerialize[key] = value - } - return toSerialize, nil -} - -func (o *BoxAdvancedOptions) UnmarshalJSON(data []byte) error { - allProperties := make(map[string]interface{}) - if err := json.Unmarshal(data, &allProperties); err != nil { - return err - } - if _, exists := allProperties["capabilities"]; !exists { - return fmt.Errorf("no value given for required property capabilities") - } - - varBoxAdvancedOptions := _BoxAdvancedOptions{} - if err := json.Unmarshal(data, &varBoxAdvancedOptions); err != nil { - return err - } - *o = BoxAdvancedOptions(varBoxAdvancedOptions) - - additionalProperties := make(map[string]interface{}) - if err := json.Unmarshal(data, &additionalProperties); err != nil { - return err - } - delete(additionalProperties, "capabilities") - o.AdditionalProperties = additionalProperties - return nil -} - -type NullableBoxAdvancedOptions struct { - value *BoxAdvancedOptions - isSet bool -} - -func (v NullableBoxAdvancedOptions) Get() *BoxAdvancedOptions { return v.value } -func (v *NullableBoxAdvancedOptions) Set(val *BoxAdvancedOptions) { - v.value = val - v.isSet = true -} -func (v NullableBoxAdvancedOptions) IsSet() bool { return v.isSet } -func (v *NullableBoxAdvancedOptions) Unset() { - v.value = nil - v.isSet = false -} -func NewNullableBoxAdvancedOptions(val *BoxAdvancedOptions) *NullableBoxAdvancedOptions { - return &NullableBoxAdvancedOptions{value: val, isSet: true} -} -func (v NullableBoxAdvancedOptions) MarshalJSON() ([]byte, error) { return json.Marshal(v.value) } -func (v *NullableBoxAdvancedOptions) UnmarshalJSON(src []byte) error { - v.isSet = true - return json.Unmarshal(src, &v.value) -} diff --git a/apps/api-client-go/model_linux_capabilities.go b/apps/api-client-go/model_linux_capabilities.go deleted file mode 100644 index 606966516..000000000 --- a/apps/api-client-go/model_linux_capabilities.go +++ /dev/null @@ -1,150 +0,0 @@ -/* -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" -) - -var _ MappedNullable = &LinuxCapabilities{} - -// LinuxCapabilities is the effective Linux capability policy for a box. -type LinuxCapabilities struct { - // Linux capabilities added to the default container capability set - Add []string `json:"add"` - // Linux capabilities removed from the container capability set - Drop []string `json:"drop"` - AdditionalProperties map[string]interface{} -} - -type _LinuxCapabilities LinuxCapabilities - -func NewLinuxCapabilities(add []string, drop []string) *LinuxCapabilities { - this := LinuxCapabilities{} - this.Add = add - this.Drop = drop - return &this -} - -func NewLinuxCapabilitiesWithDefaults() *LinuxCapabilities { - this := LinuxCapabilities{} - return &this -} - -func (o *LinuxCapabilities) GetAdd() []string { - if o == nil { - var ret []string - return ret - } - return o.Add -} - -func (o *LinuxCapabilities) GetAddOk() (*[]string, bool) { - if o == nil { - return nil, false - } - return &o.Add, true -} - -func (o *LinuxCapabilities) SetAdd(v []string) { - o.Add = v -} - -func (o *LinuxCapabilities) GetDrop() []string { - if o == nil { - var ret []string - return ret - } - return o.Drop -} - -func (o *LinuxCapabilities) GetDropOk() (*[]string, bool) { - if o == nil { - return nil, false - } - return &o.Drop, true -} - -func (o *LinuxCapabilities) SetDrop(v []string) { - o.Drop = v -} - -func (o LinuxCapabilities) MarshalJSON() ([]byte, error) { - toSerialize, err := o.ToMap() - if err != nil { - return []byte{}, err - } - return json.Marshal(toSerialize) -} - -func (o LinuxCapabilities) ToMap() (map[string]interface{}, error) { - toSerialize := map[string]interface{}{ - "add": o.Add, - "drop": o.Drop, - } - for key, value := range o.AdditionalProperties { - toSerialize[key] = value - } - return toSerialize, nil -} - -func (o *LinuxCapabilities) UnmarshalJSON(data []byte) error { - allProperties := make(map[string]interface{}) - if err := json.Unmarshal(data, &allProperties); err != nil { - return err - } - for _, requiredProperty := range []string{"add", "drop"} { - if value, exists := allProperties[requiredProperty]; !exists || value == nil { - return fmt.Errorf("no value given for required property %v", requiredProperty) - } - } - - varLinuxCapabilities := _LinuxCapabilities{} - if err := json.Unmarshal(data, &varLinuxCapabilities); err != nil { - return err - } - *o = LinuxCapabilities(varLinuxCapabilities) - - additionalProperties := make(map[string]interface{}) - if err := json.Unmarshal(data, &additionalProperties); err != nil { - return err - } - delete(additionalProperties, "add") - delete(additionalProperties, "drop") - o.AdditionalProperties = additionalProperties - return nil -} - -type NullableLinuxCapabilities struct { - value *LinuxCapabilities - isSet bool -} - -func (v NullableLinuxCapabilities) Get() *LinuxCapabilities { return v.value } -func (v *NullableLinuxCapabilities) Set(val *LinuxCapabilities) { - v.value = val - v.isSet = true -} -func (v NullableLinuxCapabilities) IsSet() bool { return v.isSet } -func (v *NullableLinuxCapabilities) Unset() { - v.value = nil - v.isSet = false -} -func NewNullableLinuxCapabilities(val *LinuxCapabilities) *NullableLinuxCapabilities { - return &NullableLinuxCapabilities{value: val, isSet: true} -} -func (v NullableLinuxCapabilities) MarshalJSON() ([]byte, error) { return json.Marshal(v.value) } -func (v *NullableLinuxCapabilities) UnmarshalJSON(src []byte) error { - v.isSet = true - return json.Unmarshal(src, &v.value) -} diff --git a/apps/api/src/box/dto/box-advanced-options.dto.ts b/apps/api/src/box/dto/box-advanced-options.dto.ts deleted file mode 100644 index 7e26b00aa..000000000 --- a/apps/api/src/box/dto/box-advanced-options.dto.ts +++ /dev/null @@ -1,30 +0,0 @@ -/* - * Copyright 2025 BoxLite AI - * SPDX-License-Identifier: AGPL-3.0 - */ - -import { ApiProperty, ApiSchema } from '@nestjs/swagger' -import { BoxAdvancedOptions, LinuxCapabilities } from '../common/box-advanced-options' - -@ApiSchema({ name: 'LinuxCapabilities' }) -export class LinuxCapabilitiesDto implements LinuxCapabilities { - @ApiProperty({ - description: 'Linux capabilities added to the default container capability set', - type: [String], - example: ['SYS_ADMIN'], - }) - add: string[] - - @ApiProperty({ - description: 'Linux capabilities removed from the container capability set', - type: [String], - example: ['NET_RAW'], - }) - drop: string[] -} - -@ApiSchema({ name: 'BoxAdvancedOptions' }) -export class BoxAdvancedOptionsDto implements BoxAdvancedOptions { - @ApiProperty({ type: LinuxCapabilitiesDto }) - capabilities: LinuxCapabilitiesDto -} diff --git a/apps/api/src/box/dto/box.dto.spec.ts b/apps/api/src/box/dto/box.dto.spec.ts index afb64dc53..737fd844c 100644 --- a/apps/api/src/box/dto/box.dto.spec.ts +++ b/apps/api/src/box/dto/box.dto.spec.ts @@ -18,7 +18,6 @@ describe('BoxDto public identity', () => { expect(dto.id).toBe(box.id) expect((dto as any).boxId).toBeUndefined() - expect(dto.advanced.capabilities.add).toEqual(['SYS_ADMIN']) - expect(dto.advanced.capabilities.drop).toEqual(['NET_RAW']) + expect((dto as any).advanced).toBeUndefined() }) }) diff --git a/apps/api/src/box/dto/box.dto.ts b/apps/api/src/box/dto/box.dto.ts index 7b3253165..aedbe1001 100644 --- a/apps/api/src/box/dto/box.dto.ts +++ b/apps/api/src/box/dto/box.dto.ts @@ -10,8 +10,6 @@ import { IsEnum, IsOptional } from 'class-validator' import { Box } from '../entities/box.entity' import { BoxDesiredState } from '../enums/box-desired-state.enum' import { BoxClass } from '../enums/box-class.enum' -import { normalizeBoxAdvancedOptions } from '../common/box-advanced-options' -import { BoxAdvancedOptionsDto } from './box-advanced-options.dto' @ApiSchema({ name: 'BoxVolume' }) export class BoxVolume { @@ -69,13 +67,6 @@ export class BoxDto { }) env: Record - @ApiProperty({ - description: 'Advanced box configuration', - type: BoxAdvancedOptionsDto, - example: { capabilities: { add: ['SYS_ADMIN'], drop: ['NET_RAW'] } }, - }) - advanced: BoxAdvancedOptionsDto - @ApiProperty({ description: 'Labels for the box', type: 'object', @@ -270,7 +261,6 @@ export class BoxDto { image: box.image, user: box.osUser, env: box.env, - advanced: normalizeBoxAdvancedOptions(box.advanced), cpu: box.cpu, gpu: box.gpu, memory: box.mem, diff --git a/apps/api/src/boxlite-rest/boxlite-box.controller.ts b/apps/api/src/boxlite-rest/boxlite-box.controller.ts index 09aa40176..d208a7db3 100644 --- a/apps/api/src/boxlite-rest/boxlite-box.controller.ts +++ b/apps/api/src/boxlite-rest/boxlite-box.controller.ts @@ -210,7 +210,7 @@ export class BoxliteBoxController { } } - @Get(['', 'strict']) + @Get() @ApiResponse({ status: 200, description: 'List boxes', @@ -227,7 +227,7 @@ export class BoxliteBoxController { } } - @Get([':boxId', ':boxId/strict']) + @Get(':boxId') @ApiResponse({ status: 200, description: 'Box details', diff --git a/apps/api/src/boxlite-rest/boxlite-rest-routing.spec.ts b/apps/api/src/boxlite-rest/boxlite-rest-routing.spec.ts index a23a8084a..544f2cd57 100644 --- a/apps/api/src/boxlite-rest/boxlite-rest-routing.spec.ts +++ b/apps/api/src/boxlite-rest/boxlite-rest-routing.spec.ts @@ -45,7 +45,7 @@ describe('BoxLite REST routing', () => { name: 'named', state: BoxState.STARTED, labels: {}, - advanced: { capabilities: { add: [], drop: [] } }, + advanced: { capabilities: { add: ['SYS_ADMIN'], drop: ['NET_RAW'] } }, }, created: false, }), @@ -54,7 +54,6 @@ describe('BoxLite REST routing', () => { name: 'named', state: BoxState.STARTED, labels: {}, - advanced: { capabilities: { add: [], drop: [] } }, }), }, }, @@ -106,6 +105,14 @@ describe('BoxLite REST routing', () => { expect(Reflect.getMetadata(PATH_METADATA, BoxliteProxyController)).toEqual(['v1/boxes', 'v1/:prefix/boxes']) }) + it('does not register capability-specific read aliases', () => { + const listPath = Reflect.getMetadata(PATH_METADATA, BoxliteBoxController.prototype.listBoxes) + const getPath = Reflect.getMetadata(PATH_METADATA, BoxliteBoxController.prototype.getBox) + + expect(listPath).toBe('/') + expect(getPath).toBe(':boxId') + }) + it('registers canonical and legacy default-prefix routes in the Nest HTTP router', async () => { await startRoutingTestApp() @@ -118,32 +125,6 @@ describe('BoxLite REST routing', () => { expect(await legacy.json()).toEqual({ boxes: [] }) }) - it('registers the strict policy-aware box read route', async () => { - await startRoutingTestApp() - - const canonical = await get('/api/v1/boxes/named/strict') - const prefixed = await get('/api/v1/default/boxes/named/strict') - - expect(canonical.status).toBe(200) - expect(await canonical.json()).toMatchObject({ - box_id: 'box-1', - advanced: { capabilities: { add: [], drop: [] } }, - }) - expect(prefixed.status).toBe(200) - }) - - it('registers the strict policy-aware box list route', async () => { - await startRoutingTestApp() - - const canonical = await get('/api/v1/boxes/strict') - const prefixed = await get('/api/v1/default/boxes/strict') - - expect(canonical.status).toBe(200) - expect(await canonical.json()).toEqual({ boxes: [] }) - expect(prefixed.status).toBe(200) - expect(await prefixed.json()).toEqual({ boxes: [] }) - }) - it('delegates strict get-or-create compatibility to the box service', async () => { await startRoutingTestApp() @@ -154,13 +135,14 @@ describe('BoxLite REST routing', () => { }) expect(response.status).toBe(200) - expect(await response.json()).toMatchObject({ + const body = await response.json() + expect(body).toMatchObject({ box_info: { box_id: 'box-1', - advanced: { capabilities: { add: [], drop: [] } }, }, created: false, }) + expect(body.box_info.advanced).toBeUndefined() }) it('rejects unknown fields at the strict create boundary', async () => { diff --git a/apps/api/src/boxlite-rest/dto/box-response.dto.ts b/apps/api/src/boxlite-rest/dto/box-response.dto.ts index b6bddfcfd..ce6423fd4 100644 --- a/apps/api/src/boxlite-rest/dto/box-response.dto.ts +++ b/apps/api/src/boxlite-rest/dto/box-response.dto.ts @@ -5,7 +5,6 @@ */ import { ApiProperty, ApiPropertyOptional, ApiSchema } from '@nestjs/swagger' -import { BoxAdvancedOptionsDto } from '../../box/dto/box-advanced-options.dto' @ApiSchema({ name: 'Box' }) export class BoxResponseDto { @@ -63,13 +62,6 @@ export class BoxResponseDto { }) memory_mib: number - @ApiProperty({ - description: 'Advanced box configuration', - type: BoxAdvancedOptionsDto, - example: { capabilities: { add: ['SYS_ADMIN'], drop: ['NET_RAW'] } }, - }) - advanced: BoxAdvancedOptionsDto - @ApiProperty({ description: 'Labels attached to the box', type: 'object', diff --git a/apps/api/src/boxlite-rest/mappers/box-to-box.mapper.spec.ts b/apps/api/src/boxlite-rest/mappers/box-to-box.mapper.spec.ts index 99dcdfc14..84b506fa4 100644 --- a/apps/api/src/boxlite-rest/mappers/box-to-box.mapper.spec.ts +++ b/apps/api/src/boxlite-rest/mappers/box-to-box.mapper.spec.ts @@ -69,7 +69,7 @@ describe('BoxLite lifecycle policy mapper', () => { expect(response.auto_resume).toBe(false) }) - it('returns the persisted capability policy', () => { + it('omits persisted capabilities from the public response', () => { const response = boxToBoxResponse({ id: 'box-1', name: 'demo', @@ -78,8 +78,7 @@ describe('BoxLite lifecycle policy mapper', () => { advanced: { capabilities: { add: ['SYS_ADMIN'], drop: ['NET_RAW'] } }, } as any) - expect(response.advanced.capabilities.add).toEqual(['SYS_ADMIN']) - expect(response.advanced.capabilities.drop).toEqual(['NET_RAW']) + expect((response as any).advanced).toBeUndefined() }) it('defaults auto_resume to true when missing', () => { @@ -91,6 +90,5 @@ describe('BoxLite lifecycle policy mapper', () => { } as any) expect(response.auto_resume).toBe(true) - expect(response.advanced).toEqual({ capabilities: { add: [], drop: [] } }) }) }) diff --git a/apps/api/src/boxlite-rest/mappers/box-to-box.mapper.ts b/apps/api/src/boxlite-rest/mappers/box-to-box.mapper.ts index 5f398fbc9..4d6b4a366 100644 --- a/apps/api/src/boxlite-rest/mappers/box-to-box.mapper.ts +++ b/apps/api/src/boxlite-rest/mappers/box-to-box.mapper.ts @@ -15,7 +15,6 @@ import { import { BoxResponseDto } from '../dto/box-response.dto' import { CreateBoxDto as RestCreateBoxDto } from '../dto/create-box.dto' import { CreateBoxDto } from '../../box/dto/create-box.dto' -import { normalizeBoxAdvancedOptions } from '../../box/common/box-advanced-options' export function boxToBoxResponse(box: BoxDto): BoxResponseDto { return { @@ -27,7 +26,6 @@ export function boxToBoxResponse(box: BoxDto): BoxResponseDto { image: box.image || '', cpus: box.cpu || 1, memory_mib: (box.memory || 1) * 1024, - advanced: normalizeBoxAdvancedOptions(box.advanced), labels: box.labels || {}, auto_pause: box.autoPause ?? DEFAULT_AUTO_PAUSE_SECONDS, auto_delete: box.autoDelete ?? AUTO_DELETE_DISABLED, @@ -65,9 +63,7 @@ function rejectUnsupportedCloudCreateOptions(dto: RestCreateBoxDto): void { return } - throw new BadRequestException( - `${unsupportedFields.join(', ')} is not supported by the cloud REST API`, - ) + throw new BadRequestException(`${unsupportedFields.join(', ')} is not supported by the cloud REST API`) } function mapState(state: string | BoxState | undefined): string { diff --git a/apps/dashboard/src/mocks/fixtures.ts b/apps/dashboard/src/mocks/fixtures.ts index eecde6a94..a0f0c8a3d 100644 --- a/apps/dashboard/src/mocks/fixtures.ts +++ b/apps/dashboard/src/mocks/fixtures.ts @@ -115,7 +115,6 @@ function buildBox(overrides: Partial & Pick): class: BoxClassEnum.SMALL, toolboxProxyUrl: 'https://mock.local', ...overrides, - advanced: overrides.advanced ?? { capabilities: { add: [], drop: [] } }, } } diff --git a/apps/hack/go-client/postprocess.sh b/apps/hack/go-client/postprocess.sh index af9db5a8e..5fa0a2262 100755 --- a/apps/hack/go-client/postprocess.sh +++ b/apps/hack/go-client/postprocess.sh @@ -30,17 +30,4 @@ EOF grep -q 'UserAgent:.*"[^"]*"' "$PROJECT_ROOT/configuration.go" || { echo "ERROR: UserAgent string not found in configuration.go" >&2; exit 1; } sed -i "s|UserAgent: *\"[^\"]*\"|UserAgent: \"${CLIENT_NAME}/\" + ClientVersion|" "$PROJECT_ROOT/configuration.go" -# encoding/json accepts null for slices, so preserve the OpenAPI non-null -# contract for LinuxCapabilities' required array fields after regeneration. -CAPABILITIES_MODEL="$PROJECT_ROOT/model_linux_capabilities.go" -REQUIRED_VALUE_CHECK='if _, exists := allProperties[requiredProperty]; !exists {' -NULL_SAFE_REQUIRED_VALUE_CHECK='if value, exists := allProperties[requiredProperty]; !exists || value == nil {' - -if [ "$(grep -Fc "$REQUIRED_VALUE_CHECK" "$CAPABILITIES_MODEL")" -ne 1 ]; then - echo "ERROR: LinuxCapabilities required-value check not found exactly once" >&2 - exit 1 -fi -sed -i 's/if _, exists := allProperties\[requiredProperty\]; !exists {/if value, exists := allProperties[requiredProperty]; !exists || value == nil {/' "$CAPABILITIES_MODEL" -grep -Fq "$NULL_SAFE_REQUIRED_VALUE_CHECK" "$CAPABILITIES_MODEL" || { echo "ERROR: LinuxCapabilities null guard was not applied" >&2; exit 1; } - echo "Postprocessed Go client at $PROJECT_ROOT" diff --git a/apps/libs/api-client/src/docs/Box.md b/apps/libs/api-client/src/docs/Box.md index 171fe821f..c4a2da0af 100644 --- a/apps/libs/api-client/src/docs/Box.md +++ b/apps/libs/api-client/src/docs/Box.md @@ -10,7 +10,6 @@ Name | Type | Description | Notes **name** | **string** | The name of the box | [default to undefined] **user** | **string** | The user associated with the project | [default to undefined] **env** | **{ [key: string]: string; }** | Environment variables for the box | [default to undefined] -**advanced** | [**BoxAdvancedOptions**](BoxAdvancedOptions.md) | Advanced box configuration | [default to undefined] **labels** | **{ [key: string]: string; }** | Labels for the box | [default to undefined] **_public** | **boolean** | Whether the box http preview is public | [default to undefined] **networkBlockAll** | **boolean** | Whether to block all network access for the box | [default to undefined] @@ -47,7 +46,6 @@ const instance: Box = { name, user, env, - advanced, labels, _public, networkBlockAll, diff --git a/apps/libs/api-client/src/docs/BoxAdvancedOptions.md b/apps/libs/api-client/src/docs/BoxAdvancedOptions.md deleted file mode 100644 index 2c9f14be0..000000000 --- a/apps/libs/api-client/src/docs/BoxAdvancedOptions.md +++ /dev/null @@ -1,9 +0,0 @@ -# BoxAdvancedOptions - -## Properties - -Name | Type | Description | Notes ------------- | ------------- | ------------- | ------------- -**capabilities** | [**LinuxCapabilities**](LinuxCapabilities.md) | Linux capability policy | [default to undefined] - -[[Back to Model list]](../README.md#documentation-for-models) diff --git a/apps/libs/api-client/src/docs/LinuxCapabilities.md b/apps/libs/api-client/src/docs/LinuxCapabilities.md deleted file mode 100644 index 891797d2c..000000000 --- a/apps/libs/api-client/src/docs/LinuxCapabilities.md +++ /dev/null @@ -1,10 +0,0 @@ -# LinuxCapabilities - -## Properties - -Name | Type | Description | Notes ------------- | ------------- | ------------- | ------------- -**add** | **Array<string>** | Linux capabilities added to the default container capability set | [default to undefined] -**drop** | **Array<string>** | Linux capabilities removed from the container capability set | [default to undefined] - -[[Back to Model list]](../README.md#documentation-for-models) diff --git a/apps/libs/api-client/src/models/box-advanced-options.ts b/apps/libs/api-client/src/models/box-advanced-options.ts deleted file mode 100644 index c8b4dc604..000000000 --- a/apps/libs/api-client/src/models/box-advanced-options.ts +++ /dev/null @@ -1,15 +0,0 @@ -/* tslint:disable */ -/* eslint-disable */ -/** - * BoxLite - * BoxLite AI platform API Docs - * - * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). - * Do not edit the class manually. - */ - -import type { LinuxCapabilities } from './linux-capabilities' - -export interface BoxAdvancedOptions { - capabilities: LinuxCapabilities -} diff --git a/apps/libs/api-client/src/models/box.ts b/apps/libs/api-client/src/models/box.ts index 7bf931db6..3df670b90 100644 --- a/apps/libs/api-client/src/models/box.ts +++ b/apps/libs/api-client/src/models/box.ts @@ -21,9 +21,6 @@ import type { BoxState } from './box-state' // May contain unused imports in some cases // @ts-ignore import type { BoxVolume } from './box-volume' -// May contain unused imports in some cases -// @ts-ignore -import type { BoxAdvancedOptions } from './box-advanced-options' export interface Box { /** @@ -46,10 +43,6 @@ export interface Box { * Environment variables for the box */ env: { [key: string]: string } - /** - * Advanced box configuration - */ - advanced: BoxAdvancedOptions /** * Labels for the box */ diff --git a/apps/libs/api-client/src/models/index.ts b/apps/libs/api-client/src/models/index.ts index 61ebc85ef..7fda66017 100644 --- a/apps/libs/api-client/src/models/index.ts +++ b/apps/libs/api-client/src/models/index.ts @@ -32,7 +32,6 @@ export * from './api-key-list'; export * from './api-key-response'; export * from './audit-log'; export * from './box'; -export * from './box-advanced-options'; export * from './box-class'; export * from './box-desired-state'; export * from './box-labels'; @@ -56,7 +55,6 @@ export * from './job'; export * from './job-status'; export * from './job-type'; export * from './log-entry'; -export * from './linux-capabilities'; export * from './metric-data-point'; export * from './metric-series'; export * from './metrics-response'; diff --git a/apps/libs/api-client/src/models/linux-capabilities.ts b/apps/libs/api-client/src/models/linux-capabilities.ts deleted file mode 100644 index c74683106..000000000 --- a/apps/libs/api-client/src/models/linux-capabilities.ts +++ /dev/null @@ -1,16 +0,0 @@ -/* tslint:disable */ -/* eslint-disable */ -/** - * BoxLite - * BoxLite AI platform API Docs - * - * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). - * Do not edit the class manually. - */ - -export interface LinuxCapabilities { - /** Linux capabilities added to the default container capability set. */ - add: Array - /** Linux capabilities removed from the container capability set. */ - drop: Array -} diff --git a/docs/architecture/container-capabilities.md b/docs/architecture/container-capabilities.md index 2a6fe26e7..6407529c0 100644 --- a/docs/architecture/container-capabilities.md +++ b/docs/architecture/container-capabilities.md @@ -16,10 +16,8 @@ container settings instead of widening the top-level box API: | C | `boxlite_advanced_options_set_capabilities_add` | `boxlite_advanced_options_set_capabilities_drop` | | CLI | repeatable `--cap-add` | repeatable `--cap-drop` | -Create inputs and inspection outputs use that same nested path. Inspection uses -a dedicated read-only advanced-info type so it exposes the effective capability -policy without leaking runtime security or health-check configuration. The CLI -flags remain familiar Docker-style shorthands and populate the nested object. +Create inputs use that nested path. The CLI flags remain familiar Docker-style +shorthands and populate the nested object. The values remain strings rather than a public enum. Linux can add capabilities independently of an SDK release, and the API host may run a @@ -65,11 +63,6 @@ runner state again immediately before invoking the runner. Missing advertisements therefore fail closed; the second runner check also narrows the selection-to-dispatch race. -Remote inspection uses the versioned strict get and list routes and rejects -every box response that omits `advanced.capabilities`. Legacy read routes stay -available for older clients, but capability-aware clients deliberately trade -old-server inspection compatibility for authoritative security metadata. - The structured host/guest protobuf carries the policy under an `advanced` message. The `-v2` feature token and capability-specific v2 job kinds keep mixed-version guests and queued jobs from silently ignoring the nested diff --git a/docs/reference/c/README.md b/docs/reference/c/README.md index 7152b7849..e21454058 100644 --- a/docs/reference/c/README.md +++ b/docs/reference/c/README.md @@ -816,13 +816,6 @@ if (code == Ok) { ### Discovery & Introspection -The original `CBoxInfo` layout remains stable for existing binaries. New code -should call `boxlite_box_info_v2`, `boxlite_get_info_v2`, and -`boxlite_list_info_v2`; `CBoxInfoV2.base` contains the original fields and the -versioned structure adds `advanced.capabilities.add` and -`advanced.capabilities.drop` string arrays. Free versioned -results with `boxlite_free_box_info_v2` or `boxlite_free_box_info_list_v2`. - #### boxlite_list_info List all boxes. @@ -922,8 +915,6 @@ BoxliteErrorCode boxlite_box_metrics( - `CBoxliteExecResult` → `boxlite_result_free()` - `CBoxInfo` → `boxlite_free_box_info()` - `CBoxInfoList` → `boxlite_free_box_info_list()` - - `CBoxInfoV2` → `boxlite_free_box_info_v2()` - - `CBoxInfoListV2` → `boxlite_free_box_info_list_v2()` - `CImagePullResult` → `boxlite_free_image_pull_result()` - `CImageInfoList` → `boxlite_free_image_info_list()` @@ -1096,13 +1087,10 @@ if (code != Ok) { | `boxlite_box_id()` | Get box ID | | `boxlite_box_free()` | Free box handle | | `boxlite_box_info()` | Get box info | -| `boxlite_box_info_v2()` | Get box info including capability policy | | `boxlite_box_metrics()` | Get box metrics | | `boxlite_execute()` | Execute command | | `boxlite_list_info()` | List all boxes | | `boxlite_get_info()` | Get box info by ID | -| `boxlite_list_info_v2()` | List boxes including capability policy | -| `boxlite_get_info_v2()` | Get box info by ID including capability policy | | `boxlite_simple_new()` | Create simple box | | `boxlite_simple_run()` | Run command (simple) | | `boxlite_simple_free()` | Free simple box | diff --git a/docs/reference/nodejs/README.md b/docs/reference/nodejs/README.md index 9cfffab77..1800179e2 100644 --- a/docs/reference/nodejs/README.md +++ b/docs/reference/nodejs/README.md @@ -214,9 +214,6 @@ Metadata about a box. | `createdAt` | `string` | Creation timestamp (ISO 8601) | | `lastUpdated` | `string` | Last state change (ISO 8601) | | `pid` | `number \| undefined` | Process ID (if running) | -| `advanced.capabilities.add` | `string[]` | Linux capabilities added to the default container set | -| `advanced.capabilities.drop` | `string[]` | Linux capabilities removed from the resulting container set | - --- ## Command Execution diff --git a/docs/reference/python/README.md b/docs/reference/python/README.md index f927c1533..4c314b718 100644 --- a/docs/reference/python/README.md +++ b/docs/reference/python/README.md @@ -244,9 +244,6 @@ Metadata about a box. | `image` | `str` | OCI image used | | `cpus` | `int` | Allocated CPU cores | | `memory_mib` | `int` | Allocated memory in MiB | -| `advanced.capabilities.add` | `List[str]` | Linux capabilities added to the default container set | -| `advanced.capabilities.drop` | `List[str]` | Linux capabilities removed from the resulting container set | - --- ### `boxlite.BoxStateInfo` diff --git a/openapi/box.openapi.yaml b/openapi/box.openapi.yaml index d34e93b8a..11ae95107 100644 --- a/openapi/box.openapi.yaml +++ b/openapi/box.openapi.yaml @@ -249,31 +249,6 @@ paths: parameters: - $ref: "#/components/parameters/prefix" - get: - operationId: listBoxesStrict - summary: List boxes with authoritative security metadata - description: | - Returns a paginated list through a route introduced with Linux - capability policy support. Clients use this route so an older server - fails instead of omitting `advanced.capabilities` and making a custom - policy appear to be the baseline. - tags: [Boxes] - parameters: - - $ref: "#/components/parameters/pageSize" - - $ref: "#/components/parameters/pageToken" - - name: status - in: query - description: Filter by box status - schema: - $ref: "#/components/schemas/BoxStatus" - responses: - "200": - description: List of boxes with authoritative capability policies - content: - application/json: - schema: - $ref: "#/components/schemas/ListBoxesResponse" - post: operationId: createBoxStrict summary: Create a box with fail-closed option handling @@ -320,7 +295,7 @@ paths: description: | Atomically adopts an existing named box or creates it. The server validates the requested options against an existing box before reuse; - clients do not infer compatibility from inspection metadata. + clients delegate compatibility decisions to the backend. tags: [Boxes] requestBody: required: true @@ -396,30 +371,6 @@ paths: "409": $ref: "#/components/responses/ConflictError" - /{prefix}/boxes/{box_id}/strict: - parameters: - - $ref: "#/components/parameters/prefix" - - $ref: "#/components/parameters/boxId" - - get: - operationId: getBoxStrict - summary: Get authoritative box details - description: | - Returns box metadata through a route introduced with Linux capability - policy support. Clients that must compare a persisted security policy - use this route so an older server fails with 404 instead of omitting - `advanced.capabilities`. - tags: [Boxes] - responses: - "200": - description: Box details with an authoritative capability policy - content: - application/json: - schema: - $ref: "#/components/schemas/Box" - "404": - $ref: "#/components/responses/NotFoundError" - # ------------------------------------------------------------------------- # Box Lifecycle # ------------------------------------------------------------------------- @@ -1612,7 +1563,7 @@ components: Box: type: object description: Box metadata (maps to BoxInfo) - required: [box_id, status, created_at, updated_at, image, cpus, memory_mib, advanced] + required: [box_id, status, created_at, updated_at, image, cpus, memory_mib] properties: box_id: type: string @@ -1661,8 +1612,6 @@ components: minimum: 128 description: Allocated memory in MiB example: 512 - advanced: - $ref: "#/components/schemas/BoxAdvancedInfo" labels: type: object additionalProperties: @@ -1862,32 +1811,6 @@ components: Names are case-insensitive, may include the `CAP_` prefix, and may be `ALL`. example: [NET_RAW] - BoxAdvancedInfo: - type: object - description: Expert-only inspection metadata safe to expose to clients. - additionalProperties: false - required: [capabilities] - properties: - capabilities: - $ref: "#/components/schemas/ContainerCapabilities" - - ContainerCapabilities: - type: object - description: Authoritative Linux capability policy for container processes. - additionalProperties: false - required: [add, drop] - properties: - add: - type: array - items: - type: string - description: Linux capabilities added to the default container set. - drop: - type: array - items: - type: string - description: Linux capabilities removed from the resulting container set. - VolumeSpec: type: object description: Host-to-guest filesystem mount diff --git a/openapi/reference-server/server.py b/openapi/reference-server/server.py index d054de0c5..a7e9eba74 100644 --- a/openapi/reference-server/server.py +++ b/openapi/reference-server/server.py @@ -379,8 +379,6 @@ async def require_auth( def box_info_to_dict(info) -> dict: - advanced = getattr(info, "advanced", None) - capabilities = getattr(advanced, "capabilities", None) return { "box_id": info.id, "name": info.name, @@ -391,12 +389,6 @@ def box_info_to_dict(info) -> dict: "image": info.image, "cpus": info.cpus, "memory_mib": info.memory_mib, - "advanced": { - "capabilities": { - "add": list(getattr(capabilities, "add", [])), - "drop": list(getattr(capabilities, "drop", [])), - } - }, "labels": {}, } @@ -674,7 +666,6 @@ async def create_box_with_options(prefix: str, req: CreateBoxRequestBase): ) -@app.get("/v1/{prefix}/boxes/strict") @app.get("/v1/{prefix}/boxes") async def list_boxes( prefix: str, @@ -690,7 +681,6 @@ async def list_boxes( return {"boxes": boxes, "next_page_token": None} -@app.get("/v1/{prefix}/boxes/{box_id}/strict") @app.get("/v1/{prefix}/boxes/{box_id}") async def get_box( prefix: str, diff --git a/sdks/c/README.md b/sdks/c/README.md index c63e3b818..a55fabcf9 100644 --- a/sdks/c/README.md +++ b/sdks/c/README.md @@ -493,12 +493,6 @@ if (boxlite_execute(box, &cmd, my_callback, NULL, &execution, &error) == Ok) { #### Discovery & Introspection -New code should use the ABI-safe `boxlite_box_info_v2`, -`boxlite_get_info_v2`, and `boxlite_list_info_v2` variants. `CBoxInfoV2` -embeds the stable v1 fields as `base`. Its capability policy is available at -`info->advanced.capabilities.add` and `.drop`; release the recursively owned -arrays with the matching `_v2` free function. - ```c // List all boxes BoxliteErrorCode boxlite_list_info( @@ -686,8 +680,6 @@ make - `CBoxliteExecResult` → `boxlite_result_free()` - `CBoxInfo` → `boxlite_free_box_info()` - `CBoxInfoList` → `boxlite_free_box_info_list()` - - `CBoxInfoV2` → `boxlite_free_box_info_v2()` - - `CBoxInfoListV2` → `boxlite_free_box_info_list_v2()` - `CImagePullResult` → `boxlite_free_image_pull_result()` - `CImageInfoList` → `boxlite_free_image_info_list()` diff --git a/sdks/c/include/boxlite.h b/sdks/c/include/boxlite.h index 09e502202..80104126c 100644 --- a/sdks/c/include/boxlite.h +++ b/sdks/c/include/boxlite.h @@ -290,35 +290,6 @@ typedef struct CBoxInfoList { // Box info list completion. typedef void (*CBoxInfoListCb)(struct CBoxInfoList*, CBoxliteError*, void*); -// Versioned box metadata that adds capability policy without changing the -// layout or array stride of the stable CBoxInfo ABI. -typedef struct CContainerCapabilities { - char **add; - int add_count; - char **drop; - int drop_count; -} CContainerCapabilities; - -typedef struct CBoxAdvancedInfo { - struct CContainerCapabilities capabilities; -} CBoxAdvancedInfo; - -typedef struct CBoxInfoV2 { - struct CBoxInfo base; - struct CBoxAdvancedInfo advanced; -} CBoxInfoV2; - -// Versioned box info completion with capability policy. -typedef void (*CBoxInfoV2Cb)(struct CBoxInfoV2*, CBoxliteError*, void*); - -typedef struct CBoxInfoListV2 { - struct CBoxInfoV2 *items; - int count; -} CBoxInfoListV2; - -// Versioned box info list completion with capability policy. -typedef void (*CBoxInfoListV2Cb)(struct CBoxInfoListV2*, CBoxliteError*, void*); - typedef struct CBoxMetrics { double cpu_percent; int64_t memory_bytes; @@ -630,29 +601,10 @@ enum BoxliteErrorCode boxlite_list_info(CBoxliteRuntime *runtime, void *user_data, CBoxliteError *out_error); -enum BoxliteErrorCode boxlite_box_info_v2(CBoxHandle *handle, - struct CBoxInfoV2 **out_info, - CBoxliteError *out_error); - -enum BoxliteErrorCode boxlite_get_info_v2(CBoxliteRuntime *runtime, - const char *id_or_name, - CBoxInfoV2Cb cb, - void *user_data, - CBoxliteError *out_error); - -enum BoxliteErrorCode boxlite_list_info_v2(CBoxliteRuntime *runtime, - CBoxInfoListV2Cb cb, - void *user_data, - CBoxliteError *out_error); - void boxlite_free_box_info(struct CBoxInfo *info); void boxlite_free_box_info_list(struct CBoxInfoList *list); -void boxlite_free_box_info_v2(struct CBoxInfoV2 *info); - -void boxlite_free_box_info_list_v2(struct CBoxInfoListV2 *list); - enum BoxliteErrorCode boxlite_box_metrics(CBoxHandle *handle, CBoxMetricsCb cb, void *user_data, diff --git a/sdks/c/src/event_queue.rs b/sdks/c/src/event_queue.rs index c3742daed..ceaa0fda8 100644 --- a/sdks/c/src/event_queue.rs +++ b/sdks/c/src/event_queue.rs @@ -12,7 +12,7 @@ use std::sync::{Condvar, Mutex}; use boxlite::BoxliteError; use crate::images::{CImageInfoList, CImagePullResult}; -use crate::info::{CBoxInfo, CBoxInfoList, CBoxInfoListV2, CBoxInfoV2}; +use crate::info::{CBoxInfo, CBoxInfoList}; use crate::metrics::{CBoxMetrics, CRuntimeMetrics}; use crate::volumes::{CVolumeInfo, CVolumeInfoList}; @@ -161,18 +161,6 @@ pub type CBoxInfoListCb = pub(crate) type CBoxInfoListFn = extern "C" fn(*mut CBoxInfoList, *mut crate::CBoxliteError, *mut c_void); -/// Versioned box info completion with capability policy. -pub type CBoxInfoV2Cb = - Option; -pub(crate) type CBoxInfoV2Fn = - extern "C" fn(*mut CBoxInfoV2, *mut crate::CBoxliteError, *mut c_void); - -/// Versioned box info list completion with capability policy. -pub type CBoxInfoListV2Cb = - Option; -pub(crate) type CBoxInfoListV2Fn = - extern "C" fn(*mut CBoxInfoListV2, *mut crate::CBoxliteError, *mut c_void); - /// Per-box metrics completion. pub type CBoxMetricsCb = Option; @@ -395,16 +383,6 @@ pub enum RuntimeEvent { user_data: usize, result: Result, BoxliteError>, }, - InfoV2 { - cb: CBoxInfoV2Fn, - user_data: usize, - result: Result, BoxliteError>, - }, - InfoListV2 { - cb: CBoxInfoListV2Fn, - user_data: usize, - result: Result, BoxliteError>, - }, Metrics { cb: CBoxMetricsFn, user_data: usize, diff --git a/sdks/c/src/info.rs b/sdks/c/src/info.rs index 95f05ce8c..08d9c88f0 100644 --- a/sdks/c/src/info.rs +++ b/sdks/c/src/info.rs @@ -12,9 +12,7 @@ use boxlite::runtime::types::BoxStatus; use crate::box_handle::BoxHandle; use crate::error::{BoxliteErrorCode, FFIError, null_pointer_error, write_error}; -use crate::event_queue::{ - CBoxInfoCb, CBoxInfoListCb, CBoxInfoListV2Cb, CBoxInfoV2Cb, RuntimeEvent, push_event, -}; +use crate::event_queue::{CBoxInfoCb, CBoxInfoListCb, RuntimeEvent, push_event}; use crate::runtime::RuntimeHandle; use crate::{CBoxHandle, CBoxliteError, CBoxliteRuntime}; @@ -40,33 +38,6 @@ pub struct CBoxInfoList { pub count: c_int, } -/// Versioned box metadata that adds capability policy without changing the -/// layout or array stride of the stable CBoxInfo ABI. -#[repr(C)] -pub struct CContainerCapabilities { - pub add: *mut *mut c_char, - pub add_count: c_int, - pub drop: *mut *mut c_char, - pub drop_count: c_int, -} - -#[repr(C)] -pub struct CBoxAdvancedInfo { - pub capabilities: CContainerCapabilities, -} - -#[repr(C)] -pub struct CBoxInfoV2 { - pub base: CBoxInfo, - pub advanced: CBoxAdvancedInfo, -} - -#[repr(C)] -pub struct CBoxInfoListV2 { - pub items: *mut CBoxInfoV2, - pub count: c_int, -} - fn to_c_str(s: &str) -> *mut c_char { CString::new(s) .map(|c| c.into_raw()) @@ -108,53 +79,6 @@ impl CBoxInfo { } } -impl CBoxInfoV2 { - pub fn from_box_info(info: &boxlite::runtime::types::BoxInfo) -> Self { - Self { - base: CBoxInfo::from_box_info(info), - advanced: CBoxAdvancedInfo::from_box_info(&info.advanced), - } - } -} - -impl CBoxAdvancedInfo { - fn from_box_info(info: &boxlite::runtime::types::BoxAdvancedInfo) -> Self { - Self { - capabilities: CContainerCapabilities::from_capabilities(&info.capabilities), - } - } -} - -impl CContainerCapabilities { - fn from_capabilities( - capabilities: &boxlite::runtime::advanced_options::ContainerCapabilities, - ) -> Self { - let (add, add_count) = to_c_str_list(&capabilities.add); - let (drop, drop_count) = to_c_str_list(&capabilities.drop); - Self { - add, - add_count, - drop, - drop_count, - } - } -} - -fn to_c_str_list(values: &[String]) -> (*mut *mut c_char, c_int) { - if values.is_empty() { - return (ptr::null_mut(), 0); - } - let mut strings: Box<[*mut c_char]> = values - .iter() - .map(|value| to_c_str(value)) - .collect::>() - .into_boxed_slice(); - let count = strings.len() as c_int; - let items = strings.as_mut_ptr(); - Box::leak(strings); - (items, count) -} - pub unsafe fn free_box_info(info: *mut CBoxInfo) { unsafe { if info.is_null() { @@ -197,74 +121,6 @@ pub unsafe fn free_box_info_list(list: *mut CBoxInfoList) { } } -pub unsafe fn free_box_info_v2(info: *mut CBoxInfoV2) { - unsafe { - if info.is_null() { - return; - } - let info = &mut *info; - free_box_info(&mut info.base); - free_box_advanced_info(&mut info.advanced); - } -} - -unsafe fn free_box_advanced_info(info: &mut CBoxAdvancedInfo) { - unsafe { - free_container_capabilities(&mut info.capabilities); - } -} - -unsafe fn free_container_capabilities(capabilities: &mut CContainerCapabilities) { - unsafe { - free_str_list(capabilities.add, capabilities.add_count); - free_str_list(capabilities.drop, capabilities.drop_count); - } -} - -pub unsafe fn free_box_info_v2_ptr(info: *mut CBoxInfoV2) { - unsafe { - if info.is_null() { - return; - } - free_box_info_v2(info); - drop(Box::from_raw(info)); - } -} - -pub unsafe fn free_box_info_list_v2(list: *mut CBoxInfoListV2) { - unsafe { - if list.is_null() { - return; - } - let list = &mut *list; - for index in 0..list.count { - free_box_info_v2(list.items.add(index as usize)); - } - if !list.items.is_null() { - drop(Box::from_raw(ptr::slice_from_raw_parts_mut( - list.items, - list.count as usize, - ))); - } - drop(Box::from_raw(list)); - } -} - -unsafe fn free_str_list(values: *mut *mut c_char, count: c_int) { - unsafe { - if values.is_null() { - return; - } - for index in 0..count { - free_str(*values.add(index as usize)); - } - drop(Box::from_raw(ptr::slice_from_raw_parts_mut( - values, - count as usize, - ))); - } -} - unsafe fn free_str(s: *mut c_char) { if !s.is_null() { #[cfg(test)] @@ -305,36 +161,6 @@ pub unsafe extern "C" fn boxlite_list_info( box_list(runtime, cb, user_data, out_error) } -#[unsafe(no_mangle)] -pub unsafe extern "C" fn boxlite_box_info_v2( - handle: *mut CBoxHandle, - out_info: *mut *mut CBoxInfoV2, - out_error: *mut CBoxliteError, -) -> BoxliteErrorCode { - box_info_v2(handle, out_info, out_error) -} - -#[unsafe(no_mangle)] -pub unsafe extern "C" fn boxlite_get_info_v2( - runtime: *mut CBoxliteRuntime, - id_or_name: *const c_char, - cb: CBoxInfoV2Cb, - user_data: *mut c_void, - out_error: *mut CBoxliteError, -) -> BoxliteErrorCode { - box_info_by_id_v2(runtime, id_or_name, cb, user_data, out_error) -} - -#[unsafe(no_mangle)] -pub unsafe extern "C" fn boxlite_list_info_v2( - runtime: *mut CBoxliteRuntime, - cb: CBoxInfoListV2Cb, - user_data: *mut c_void, - out_error: *mut CBoxliteError, -) -> BoxliteErrorCode { - box_list_v2(runtime, cb, user_data, out_error) -} - #[unsafe(no_mangle)] pub unsafe extern "C" fn boxlite_free_box_info(info: *mut CBoxInfo) { free_box_info_ptr(info) @@ -345,16 +171,6 @@ pub unsafe extern "C" fn boxlite_free_box_info_list(list: *mut CBoxInfoList) { free_box_info_list(list) } -#[unsafe(no_mangle)] -pub unsafe extern "C" fn boxlite_free_box_info_v2(info: *mut CBoxInfoV2) { - free_box_info_v2_ptr(info) -} - -#[unsafe(no_mangle)] -pub unsafe extern "C" fn boxlite_free_box_info_list_v2(list: *mut CBoxInfoListV2) { - free_box_info_list_v2(list) -} - unsafe fn box_info( handle: *mut BoxHandle, out_info: *mut *mut CBoxInfo, @@ -377,27 +193,6 @@ unsafe fn box_info( } } -unsafe fn box_info_v2( - handle: *mut BoxHandle, - out_info: *mut *mut CBoxInfoV2, - out_error: *mut FFIError, -) -> BoxliteErrorCode { - unsafe { - if handle.is_null() { - write_error(out_error, null_pointer_error("handle")); - return BoxliteErrorCode::InvalidArgument; - } - if out_info.is_null() { - write_error(out_error, null_pointer_error("out_info")); - return BoxliteErrorCode::InvalidArgument; - } - - let info = (*handle).handle.info(); - *out_info = Box::into_raw(Box::new(CBoxInfoV2::from_box_info(&info))); - BoxliteErrorCode::Ok - } -} - unsafe fn box_info_by_id( runtime: *mut RuntimeHandle, id_or_name: *const c_char, @@ -451,58 +246,6 @@ unsafe fn box_info_by_id( } } -unsafe fn box_info_by_id_v2( - runtime: *mut RuntimeHandle, - id_or_name: *const c_char, - cb: CBoxInfoV2Cb, - user_data: *mut c_void, - out_error: *mut FFIError, -) -> BoxliteErrorCode { - unsafe { - if runtime.is_null() { - write_error(out_error, null_pointer_error("runtime")); - return BoxliteErrorCode::InvalidArgument; - } - - let id_or_name = match crate::util::c_str_to_string(id_or_name) { - Ok(value) => value, - Err(error) => { - write_error(out_error, error); - return BoxliteErrorCode::InvalidArgument; - } - }; - let cb = crate::unwrap_cb_or_return!(cb, out_error); - - let runtime = &*runtime; - let runtime_clone = runtime.runtime.clone(); - let queue = runtime.queue.clone(); - let user_data = user_data as usize; - runtime.tokio_rt.spawn(async move { - let result = match runtime_clone.get_info(&id_or_name).await { - Ok(Some(info)) => Ok(crate::event_queue::OwnedFfiPtr::new_with( - Box::new(CBoxInfoV2::from_box_info(&info)), - free_box_info_v2_ptr, - )), - Ok(None) => Err(BoxliteError::NotFound(format!( - "Box not found: {id_or_name}" - ))), - Err(error) => Err(error), - }; - push_event( - &queue, - RuntimeEvent::InfoV2 { - cb, - user_data, - result, - }, - ) - .await; - }); - - BoxliteErrorCode::Ok - } -} - unsafe fn box_list( runtime: *mut RuntimeHandle, cb: CBoxInfoListCb, @@ -555,117 +298,3 @@ unsafe fn box_list( BoxliteErrorCode::Ok } } - -unsafe fn box_list_v2( - runtime: *mut RuntimeHandle, - cb: CBoxInfoListV2Cb, - user_data: *mut c_void, - out_error: *mut FFIError, -) -> BoxliteErrorCode { - unsafe { - if runtime.is_null() { - write_error(out_error, null_pointer_error("runtime")); - return BoxliteErrorCode::InvalidArgument; - } - let cb = crate::unwrap_cb_or_return!(cb, out_error); - - let runtime = &*runtime; - let runtime_clone = runtime.runtime.clone(); - let queue = runtime.queue.clone(); - let user_data = user_data as usize; - runtime.tokio_rt.spawn(async move { - let result = runtime_clone.list_info().await.map(|boxes| { - let mut items = boxes - .iter() - .map(CBoxInfoV2::from_box_info) - .collect::>() - .into_boxed_slice(); - let count = items.len() as c_int; - let items_ptr = if items.is_empty() { - ptr::null_mut() - } else { - let items_ptr = items.as_mut_ptr(); - Box::leak(items); - items_ptr - }; - crate::event_queue::OwnedFfiPtr::new_with( - Box::new(CBoxInfoListV2 { - items: items_ptr, - count, - }), - free_box_info_list_v2, - ) - }); - push_event( - &queue, - RuntimeEvent::InfoListV2 { - cb, - user_data, - result, - }, - ) - .await; - }); - - BoxliteErrorCode::Ok - } -} - -#[cfg(test)] -mod tests { - use super::*; - use boxlite::{BoxAdvancedInfo, BoxID, BoxInfo, ContainerCapabilities, HealthStatus}; - use std::collections::HashMap; - use std::ffi::CStr; - - #[test] - fn box_info_v2_preserves_capability_policy() { - let _free_str_guard = crate::FREE_STR_LOCK.lock().unwrap(); - let free_str_calls_before = crate::FREE_STR_CALLS.load(std::sync::atomic::Ordering::SeqCst); - let now = "2026-01-01T00:00:00Z".parse().unwrap(); - let source = BoxInfo { - id: BoxID::parse("c-info-v2").unwrap(), - name: Some("custom-policy".into()), - status: BoxStatus::Configured, - created_at: now, - last_updated: now, - pid: None, - image: "alpine:latest".into(), - cpus: 1, - memory_mib: 512, - advanced: BoxAdvancedInfo { - capabilities: ContainerCapabilities { - add: vec!["NET_ADMIN".into()], - drop: vec!["NET_RAW".into(), "MKNOD".into()], - }, - }, - labels: HashMap::new(), - auto_pause: 0, - auto_delete: 0, - auto_resume: true, - health_status: HealthStatus::new(), - exit_code: None, - }; - - let info = Box::into_raw(Box::new(CBoxInfoV2::from_box_info(&source))); - unsafe { - assert_eq!((*info).advanced.capabilities.add_count, 1); - assert_eq!((*info).advanced.capabilities.drop_count, 2); - assert_eq!( - CStr::from_ptr(*(*info).advanced.capabilities.add) - .to_str() - .unwrap(), - "NET_ADMIN" - ); - assert_eq!( - CStr::from_ptr(*(*info).advanced.capabilities.drop.add(1)) - .to_str() - .unwrap(), - "MKNOD" - ); - free_box_info_v2_ptr(info); - } - let free_str_calls_after = crate::FREE_STR_CALLS.load(std::sync::atomic::Ordering::SeqCst); - assert_eq!(free_str_calls_after - free_str_calls_before, 7); - } -} diff --git a/sdks/c/src/lib.rs b/sdks/c/src/lib.rs index 9f52a7a4e..f6efa5970 100644 --- a/sdks/c/src/lib.rs +++ b/sdks/c/src/lib.rs @@ -56,10 +56,6 @@ pub type CBoxliteError = error::FFIError; pub type CBoxliteExecResult = exec::ExecResult; pub type CBoxInfo = info::CBoxInfo; pub type CBoxInfoList = info::CBoxInfoList; -pub type CContainerCapabilities = info::CContainerCapabilities; -pub type CBoxAdvancedInfo = info::CBoxAdvancedInfo; -pub type CBoxInfoV2 = info::CBoxInfoV2; -pub type CBoxInfoListV2 = info::CBoxInfoListV2; pub type CBoxMetrics = metrics::CBoxMetrics; pub type CExecutionHandle = exec::ExecutionHandle; pub type CImageInfoList = images::CImageInfoList; diff --git a/sdks/c/src/runtime.rs b/sdks/c/src/runtime.rs index 03a632ddc..6073b363e 100644 --- a/sdks/c/src/runtime.rs +++ b/sdks/c/src/runtime.rs @@ -616,16 +616,6 @@ unsafe fn dispatch_event(event: RuntimeEvent) { user_data, result, } => dispatch_handle_event::(result, user_data, cb), - RuntimeEvent::InfoV2 { - cb, - user_data, - result, - } => dispatch_handle_event::(result, user_data, cb), - RuntimeEvent::InfoListV2 { - cb, - user_data, - result, - } => dispatch_handle_event::(result, user_data, cb), RuntimeEvent::Metrics { cb, user_data, diff --git a/sdks/go/README.md b/sdks/go/README.md index 6f9fa45a7..81d0fa176 100644 --- a/sdks/go/README.md +++ b/sdks/go/README.md @@ -109,9 +109,6 @@ for _, image := range cached { box, err := runtime.Create(ctx, "alpine:latest", boxlite.WithAdvancedOptions(advanced)) ``` -Inspection uses the same grouping: `info.Advanced.Capabilities.Add` and -`info.Advanced.Capabilities.Drop`. - ## Development Build from source (requires Rust toolchain): diff --git a/sdks/go/bridge.c b/sdks/go/bridge.c index 203002274..a8b27ce8c 100644 --- a/sdks/go/bridge.c +++ b/sdks/go/bridge.c @@ -27,8 +27,8 @@ extern void goBoxliteOnVolume(CVolumeInfo *info, CBoxliteError *err, void *ud); extern void goBoxliteOnVolumeList(CVolumeInfoList *list, CBoxliteError *err, void *ud); extern void goBoxliteOnVolumeRemove(CBoxliteError *err, void *ud); -extern void goBoxliteOnInfoV2(CBoxInfoV2 *info, CBoxliteError *err, void *ud); -extern void goBoxliteOnInfoListV2(CBoxInfoListV2 *list, CBoxliteError *err, void *ud); +extern void goBoxliteOnInfo(CBoxInfo *info, CBoxliteError *err, void *ud); +extern void goBoxliteOnInfoList(CBoxInfoList *list, CBoxliteError *err, void *ud); extern void goBoxliteOnBoxMetrics(CBoxMetrics *m, CBoxliteError *err, void *ud); extern void goBoxliteOnRuntimeMetrics(CRuntimeMetrics *m, CBoxliteError *err, void *ud); @@ -62,8 +62,8 @@ CBoxVolumeGetCb cbVolumeGet(void) { return (CBoxVolumeGetCb)goBoxliteOnVolume; } CBoxVolumeListCb cbVolumeList(void) { return (CBoxVolumeListCb)goBoxliteOnVolumeList; } CBoxVolumeRemoveCb cbVolumeRemove(void) { return (CBoxVolumeRemoveCb)goBoxliteOnVolumeRemove; } -CBoxInfoV2Cb cbInfoV2(void) { return (CBoxInfoV2Cb)goBoxliteOnInfoV2; } -CBoxInfoListV2Cb cbInfoListV2(void) { return (CBoxInfoListV2Cb)goBoxliteOnInfoListV2; } +CBoxInfoCb cbInfo(void) { return (CBoxInfoCb)goBoxliteOnInfo; } +CBoxInfoListCb cbInfoList(void) { return (CBoxInfoListCb)goBoxliteOnInfoList; } CBoxMetricsCb cbBoxMetrics(void) { return (CBoxMetricsCb)goBoxliteOnBoxMetrics; } CRuntimeMetricsCb cbRuntimeMetrics(void) { return (CRuntimeMetricsCb)goBoxliteOnRuntimeMetrics; } diff --git a/sdks/go/bridge.h b/sdks/go/bridge.h index 72dc24d44..0cee21cc8 100644 --- a/sdks/go/bridge.h +++ b/sdks/go/bridge.h @@ -27,8 +27,8 @@ extern CBoxVolumeListCb cbVolumeList(void); extern CBoxVolumeGetCb cbVolumeGet(void); extern CBoxVolumeRemoveCb cbVolumeRemove(void); -extern CBoxInfoV2Cb cbInfoV2(void); -extern CBoxInfoListV2Cb cbInfoListV2(void); +extern CBoxInfoCb cbInfo(void); +extern CBoxInfoListCb cbInfoList(void); extern CBoxMetricsCb cbBoxMetrics(void); extern CRuntimeMetricsCb cbRuntimeMetrics(void); diff --git a/sdks/go/bridge_callback.go b/sdks/go/bridge_callback.go index d5e8c0090..953adc2c6 100644 --- a/sdks/go/bridge_callback.go +++ b/sdks/go/bridge_callback.go @@ -307,15 +307,15 @@ func goBoxliteOnVolumeRemove(errPtr *C.CBoxliteError, userData unsafe.Pointer) { // ─── Info callbacks ──────────────────────────────────────────────────────── -//export goBoxliteOnInfoV2 -func goBoxliteOnInfoV2(info *C.CBoxInfoV2, errPtr *C.CBoxliteError, userData unsafe.Pointer) { +//export goBoxliteOnInfo +func goBoxliteOnInfo(info *C.CBoxInfo, errPtr *C.CBoxliteError, userData unsafe.Pointer) { h := ptrToHandle(userData) if h == 0 { return } - if !claimOrFreePayload(h, &info, func(i **C.CBoxInfoV2) { + if !claimOrFreePayload(h, &info, func(i **C.CBoxInfo) { if i != nil && *i != nil { - C.boxlite_free_box_info_v2(*i) + C.boxlite_free_box_info(*i) } }) { return @@ -333,20 +333,20 @@ func goBoxliteOnInfoV2(info *C.CBoxInfoV2, errPtr *C.CBoxliteError, userData uns ch <- infoResult{} return } - v := cBoxInfoV2ToGo(info) - C.boxlite_free_box_info_v2(info) + v := cBoxInfoToGo(info) + C.boxlite_free_box_info(info) ch <- infoResult{value: &v} } -//export goBoxliteOnInfoListV2 -func goBoxliteOnInfoListV2(list *C.CBoxInfoListV2, errPtr *C.CBoxliteError, userData unsafe.Pointer) { +//export goBoxliteOnInfoList +func goBoxliteOnInfoList(list *C.CBoxInfoList, errPtr *C.CBoxliteError, userData unsafe.Pointer) { h := ptrToHandle(userData) if h == 0 { return } - if !claimOrFreePayload(h, &list, func(l **C.CBoxInfoListV2) { + if !claimOrFreePayload(h, &list, func(l **C.CBoxInfoList) { if l != nil && *l != nil { - C.boxlite_free_box_info_list_v2(*l) + C.boxlite_free_box_info_list(*l) } }) { return @@ -360,9 +360,9 @@ func goBoxliteOnInfoListV2(list *C.CBoxInfoListV2, errPtr *C.CBoxliteError, user ch <- infoListResult{err: err} return } - out := convertBoxInfoListV2(list) + out := convertBoxInfoList(list) if list != nil { - C.boxlite_free_box_info_list_v2(list) + C.boxlite_free_box_info_list(list) } ch <- infoListResult{value: out} } diff --git a/sdks/go/info.go b/sdks/go/info.go index 337f14397..fab7af28a 100644 --- a/sdks/go/info.go +++ b/sdks/go/info.go @@ -22,10 +22,6 @@ const ( ) // BoxInfo holds information about a box. -type BoxAdvancedInfo struct { - Capabilities ContainerCapabilities -} - type BoxInfo struct { ID string Name string @@ -38,7 +34,6 @@ type BoxInfo struct { AutoPause uint32 AutoDelete uint32 AutoResume bool - Advanced BoxAdvancedInfo CreatedAt time.Time } @@ -47,15 +42,15 @@ type BoxInfo struct { // boxlite_box_info is synchronous on the C side (it reads cached fields on // the handle), so no drain participation is required. func (b *Box) Info(_ context.Context) (*BoxInfo, error) { - var cInfo *C.CBoxInfoV2 + var cInfo *C.CBoxInfo var cerr C.CBoxliteError - code := C.boxlite_box_info_v2(b.handle, &cInfo, &cerr) + code := C.boxlite_box_info(b.handle, &cInfo, &cerr) if code != C.Ok { return nil, freeError(&cerr) } - defer C.boxlite_free_box_info_v2(cInfo) + defer C.boxlite_free_box_info(cInfo) - info := cBoxInfoV2ToGo(cInfo) + info := cBoxInfoToGo(cInfo) if info.Name != "" && b.name == "" { b.name = info.Name } @@ -70,7 +65,7 @@ func (r *Runtime) ListInfo(ctx context.Context) ([]BoxInfo, error) { h := registerHandleForDispatch(cgo.NewHandle(ch)) var cerr C.CBoxliteError - code := C.boxlite_list_info_v2(r.handle, C.cbInfoListV2(), handleToPtr(h), &cerr) + code := C.boxlite_list_info(r.handle, C.cbInfoList(), handleToPtr(h), &cerr) if code != C.Ok { deleteHandleForDispatch(h) return nil, freeError(&cerr) @@ -99,7 +94,7 @@ func (r *Runtime) GetInfo(ctx context.Context, idOrName string) (*BoxInfo, error h := registerHandleForDispatch(cgo.NewHandle(ch)) var cerr C.CBoxliteError - code := C.boxlite_get_info_v2(r.handle, cID, C.cbInfoV2(), handleToPtr(h), &cerr) + code := C.boxlite_get_info(r.handle, cID, C.cbInfo(), handleToPtr(h), &cerr) if code != C.Ok { deleteHandleForDispatch(h) return nil, freeError(&cerr) @@ -117,59 +112,34 @@ func (r *Runtime) GetInfo(ctx context.Context, idOrName string) (*BoxInfo, error } } -func cBoxInfoV2ToGo(info *C.CBoxInfoV2) BoxInfo { - base := &info.base - pid := int(base.pid) +func cBoxInfoToGo(info *C.CBoxInfo) BoxInfo { + pid := int(info.pid) return BoxInfo{ - ID: cString(base.id), - Name: cString(base.name), - Image: cString(base.image), - State: State(cString(base.status)), - Running: base.running != 0, + ID: cString(info.id), + Name: cString(info.name), + Image: cString(info.image), + State: State(cString(info.status)), + Running: info.running != 0, PID: pid, - CPUs: int(base.cpus), - MemoryMiB: int(base.memory_mib), - AutoPause: uint32(base.auto_pause), - AutoDelete: uint32(base.auto_delete), - AutoResume: base.auto_resume != 0, - Advanced: BoxAdvancedInfo{ - Capabilities: ContainerCapabilities{ - Add: cStringList( - info.advanced.capabilities.add, - int(info.advanced.capabilities.add_count), - ), - Drop: cStringList( - info.advanced.capabilities.drop, - int(info.advanced.capabilities.drop_count), - ), - }, - }, - CreatedAt: time.Unix(int64(base.created_at), 0), - } -} - -func cStringList(values **C.char, count int) []string { - if values == nil || count == 0 { - return nil - } - cValues := unsafe.Slice(values, count) - result := make([]string, len(cValues)) - for index, value := range cValues { - result[index] = cString(value) + CPUs: int(info.cpus), + MemoryMiB: int(info.memory_mib), + AutoPause: uint32(info.auto_pause), + AutoDelete: uint32(info.auto_delete), + AutoResume: info.auto_resume != 0, + CreatedAt: time.Unix(int64(info.created_at), 0), } - return result } -// convertBoxInfoListV2 materialises a CBoxInfoListV2* into Go BoxInfo slice. +// convertBoxInfoList materialises a CBoxInfoList* into Go BoxInfo slice. // The caller is responsible for freeing the C list afterwards. -func convertBoxInfoListV2(list *C.CBoxInfoListV2) []BoxInfo { +func convertBoxInfoList(list *C.CBoxInfoList) []BoxInfo { if list == nil || list.count == 0 || list.items == nil { return nil } items := unsafe.Slice(list.items, int(list.count)) out := make([]BoxInfo, len(items)) for i := range items { - out[i] = cBoxInfoV2ToGo(&items[i]) + out[i] = cBoxInfoToGo(&items[i]) } return out } diff --git a/sdks/node/README.md b/sdks/node/README.md index e88476ce1..cef395bc0 100644 --- a/sdks/node/README.md +++ b/sdks/node/README.md @@ -220,7 +220,6 @@ console.log(pwdResult.stdout); // "/tmp\n" console.log(box.id); // ULID console.log(box.name); // Optional name console.log(box.info()); // Metadata -// box.info().advanced.capabilities.add / .drop // Cleanup await box.stop(); diff --git a/sdks/node/lib/native-contracts.ts b/sdks/node/lib/native-contracts.ts index 0fd7f95b3..667552989 100644 --- a/sdks/node/lib/native-contracts.ts +++ b/sdks/node/lib/native-contracts.ts @@ -228,15 +228,6 @@ export interface JsBoxStateInfo { pid?: number; } -export interface JsContainerCapabilitiesInfo { - add: string[]; - drop: string[]; -} - -export interface JsBoxAdvancedInfo { - capabilities: JsContainerCapabilitiesInfo; -} - export interface JsBoxInfo { id: string; name?: string; @@ -245,7 +236,6 @@ export interface JsBoxInfo { image: string; cpus: number; memoryMib: number; - advanced: JsBoxAdvancedInfo; autoPause: number; autoDelete: number; autoResume: boolean; diff --git a/sdks/node/src/info.rs b/sdks/node/src/info.rs index dcdf1cad2..f1c24e374 100644 --- a/sdks/node/src/info.rs +++ b/sdks/node/src/info.rs @@ -95,21 +95,6 @@ impl From for JsBoxStateInfo { // BoxInfo - Container info with nested state // ============================================================================ -/// Linux capability policy returned by box inspection. -#[napi(object)] -#[derive(Clone, Debug)] -pub struct JsContainerCapabilitiesInfo { - pub add: Vec, - pub drop: Vec, -} - -/// Expert-only, inspection-safe metadata about a box. -#[napi(object)] -#[derive(Clone, Debug)] -pub struct JsBoxAdvancedInfo { - pub capabilities: JsContainerCapabilitiesInfo, -} - /// Public metadata about a box (returned by list operations). /// /// Provides read-only information about a box's identity, configuration, @@ -138,9 +123,6 @@ pub struct JsBoxInfo { /// Allocated memory in MiB pub memory_mib: u32, - /// Expert-only inspection metadata. - pub advanced: JsBoxAdvancedInfo, - /// Idle time in seconds before AutoPause; 0 disables it. #[napi(js_name = "autoPause")] pub auto_pause: u32, @@ -166,13 +148,6 @@ impl From for JsBoxInfo { failures: info.health_status.failures, last_check: info.health_status.last_check.map(|dt| dt.to_rfc3339()), }; - let advanced = JsBoxAdvancedInfo { - capabilities: JsContainerCapabilitiesInfo { - add: info.advanced.capabilities.add, - drop: info.advanced.capabilities.drop, - }, - }; - Self { id: info.id.to_string(), name: info.name, @@ -181,7 +156,6 @@ impl From for JsBoxInfo { image: info.image, cpus: info.cpus, memory_mib: info.memory_mib, - advanced, auto_pause: info.auto_pause, auto_delete: info.auto_delete, auto_resume: info.auto_resume, diff --git a/sdks/node/src/lib.rs b/sdks/node/src/lib.rs index cd1f8d832..010eb8118 100644 --- a/sdks/node/src/lib.rs +++ b/sdks/node/src/lib.rs @@ -26,10 +26,7 @@ pub use box_handle::JsBox; pub use copy::JsCopyOptions; pub use exec::{JsExecResult, JsExecStderr, JsExecStdin, JsExecStdout, JsExecution}; pub use images::{JsImageHandle, JsImageInfo, JsImagePullResult}; -pub use info::{ - JsBoxAdvancedInfo, JsBoxInfo, JsBoxStateInfo, JsContainerCapabilitiesInfo, JsHealthState, - JsHealthStatus, -}; +pub use info::{JsBoxInfo, JsBoxStateInfo, JsHealthState, JsHealthStatus}; pub use metrics::{JsBoxMetrics, JsRuntimeMetrics}; pub use network::{JsBoxConnection, JsBoxTunnel, JsNetworkHandle}; pub use options::{ diff --git a/sdks/python/README.md b/sdks/python/README.md index cf7c09705..5415926b2 100644 --- a/sdks/python/README.md +++ b/sdks/python/README.md @@ -293,9 +293,6 @@ options = boxlite.BoxOptions( box = runtime.create(options) ``` -Inspection returns the effective policy at -`info.advanced.capabilities.add` and `info.advanced.capabilities.drop`. - ### Box Handle #### `boxlite.Box` diff --git a/sdks/python/boxlite/__init__.py b/sdks/python/boxlite/__init__.py index 9cfdb1cbc..492ee9c91 100644 --- a/sdks/python/boxlite/__init__.py +++ b/sdks/python/boxlite/__init__.py @@ -12,7 +12,6 @@ AccessToken, AdvancedBoxOptions, ApiKeyCredential, - BoxAdvancedInfo, Box, BoxInfo, Boxlite, @@ -21,8 +20,8 @@ BoxOptions, BoxStateInfo, CloneOptions, - CopyOptions, ContainerCapabilities, + CopyOptions, ExecStderr, ExecStdout, Execution, @@ -68,7 +67,6 @@ "ImageInfo", "ImagePullResult", "BoxInfo", - "BoxAdvancedInfo", "BoxStateInfo", "HealthState", "HealthStatus", diff --git a/sdks/python/src/info.rs b/sdks/python/src/info.rs index 5395bda93..140582083 100644 --- a/sdks/python/src/info.rs +++ b/sdks/python/src/info.rs @@ -1,8 +1,6 @@ use boxlite::{BoxInfo, BoxStateInfo, BoxStatus, HealthState as CoreHealthState}; use pyo3::prelude::*; -use crate::advanced_options::PyContainerCapabilities; - // ============================================================================ // HealthState - Health check state enumeration // ============================================================================ @@ -161,27 +159,6 @@ impl From for PyBoxStateInfo { // BoxInfo - Container info with nested state // ============================================================================ -/// Expert-only, inspection-safe metadata about a box. -#[pyclass(name = "BoxAdvancedInfo")] -#[derive(Clone)] -pub(crate) struct PyBoxAdvancedInfo { - #[pyo3(get)] - pub(crate) capabilities: PyContainerCapabilities, -} - -#[pymethods] -impl PyBoxAdvancedInfo { - fn __repr__(&self) -> String { - serde_json::to_string_pretty(&serde_json::json!({ - "capabilities": { - "add": self.capabilities.add, - "drop": self.capabilities.drop, - } - })) - .unwrap_or_default() - } -} - #[pyclass(name = "BoxInfo")] #[derive(Clone)] pub(crate) struct PyBoxInfo { @@ -199,9 +176,6 @@ pub(crate) struct PyBoxInfo { pub(crate) cpus: u8, #[pyo3(get)] pub(crate) memory_mib: u32, - #[pyo3(get)] - pub(crate) advanced: PyBoxAdvancedInfo, - #[pyo3(get)] pub(crate) auto_pause: u32, #[pyo3(get)] pub(crate) auto_delete: u32, @@ -225,12 +199,6 @@ impl PyBoxInfo { "image": self.image, "cpus": self.cpus, "memory_mib": self.memory_mib, - "advanced": { - "capabilities": { - "add": self.advanced.capabilities.add, - "drop": self.advanced.capabilities.drop, - } - }, "auto_pause": self.auto_pause, "auto_delete": self.auto_delete, "auto_resume": self.auto_resume, @@ -254,13 +222,6 @@ impl From for PyBoxInfo { failures: info.health_status.failures, last_check: info.health_status.last_check.map(|dt| dt.to_rfc3339()), }; - let advanced = PyBoxAdvancedInfo { - capabilities: PyContainerCapabilities { - add: info.advanced.capabilities.add, - drop: info.advanced.capabilities.drop, - }, - }; - PyBoxInfo { id: info.id.to_string(), name: info.name, @@ -269,7 +230,6 @@ impl From for PyBoxInfo { image: info.image, cpus: info.cpus, memory_mib: info.memory_mib, - advanced, auto_pause: info.auto_pause, auto_delete: info.auto_delete, auto_resume: info.auto_resume, diff --git a/sdks/python/src/lib.rs b/sdks/python/src/lib.rs index fbe37295f..267560c9e 100644 --- a/sdks/python/src/lib.rs +++ b/sdks/python/src/lib.rs @@ -20,7 +20,7 @@ use crate::advanced_options::{ use crate::box_handle::PyBox; use crate::exec::{PyExecStderr, PyExecStdin, PyExecStdout, PyExecution}; use crate::images::{PyImageHandle, PyImageInfo, PyImagePullResult}; -use crate::info::{PyBoxAdvancedInfo, PyBoxInfo, PyBoxStateInfo, PyHealthState, PyHealthStatus}; +use crate::info::{PyBoxInfo, PyBoxStateInfo, PyHealthState, PyHealthStatus}; use crate::metrics::{PyBoxMetrics, PyRuntimeMetrics}; use crate::network::{PyBoxConnection, PyBoxTunnel, PyNetworkHandle}; use crate::options::{ @@ -55,7 +55,6 @@ fn boxlite_python(m: &Bound<'_, PyModule>) -> PyResult<()> { m.add_class::()?; m.add_class::()?; m.add_class::()?; - m.add_class::()?; m.add_class::()?; m.add_class::()?; m.add_class::()?; diff --git a/src/boxlite/src/lib.rs b/src/boxlite/src/lib.rs index c1e6c5cc9..4f81fb327 100644 --- a/src/boxlite/src/lib.rs +++ b/src/boxlite/src/lib.rs @@ -58,9 +58,7 @@ pub use runtime::options::{ pub const VERSION: &str = env!("CARGO_PKG_VERSION"); pub use runtime::id::{BaseDiskID, BaseDiskIDMint, BoxID, BoxIDMint}; pub use runtime::types::ContainerID; -pub use runtime::types::{ - BoxAdvancedInfo, BoxInfo, BoxLifecyclePolicy, BoxState, BoxStateInfo, BoxStatus, -}; +pub use runtime::types::{BoxInfo, BoxLifecyclePolicy, BoxState, BoxStateInfo, BoxStatus}; #[cfg(feature = "rest")] pub use rest::credential::{AccessToken, ApiKeyCredential, Credential}; diff --git a/src/boxlite/src/rest/runtime.rs b/src/boxlite/src/rest/runtime.rs index 5ba4c3c25..a81843475 100644 --- a/src/boxlite/src/rest/runtime.rs +++ b/src/boxlite/src/rest/runtime.rs @@ -151,10 +151,10 @@ impl RuntimeBackend for RestRuntime { } async fn get(&self, id_or_name: &str) -> BoxliteResult> { - let path = format!("/boxes/{id_or_name}/strict"); + let path = format!("/boxes/{id_or_name}"); match self.client.get::(&path).await { Ok(resp) => { - let info = resp.to_authoritative_box_info()?; + let info = resp.to_box_info()?; let rest_box = Arc::new(RestBox::new(self.client.clone(), info)); Ok(Some(litebox_from_rest(rest_box))) } @@ -164,20 +164,17 @@ impl RuntimeBackend for RestRuntime { } async fn get_info(&self, id_or_name: &str) -> BoxliteResult> { - let path = format!("/boxes/{id_or_name}/strict"); + let path = format!("/boxes/{id_or_name}"); match self.client.get::(&path).await { - Ok(resp) => Ok(Some(resp.to_authoritative_box_info()?)), + Ok(resp) => Ok(Some(resp.to_box_info()?)), Err(BoxliteError::NotFound(_)) => Ok(None), Err(e) => Err(e), } } async fn list_info(&self) -> BoxliteResult> { - let resp: ListBoxesResponse = self.client.get("/boxes/strict").await?; - resp.boxes - .iter() - .map(BoxResponse::to_authoritative_box_info) - .collect() + let resp: ListBoxesResponse = self.client.get("/boxes").await?; + resp.boxes.iter().map(BoxResponse::to_box_info).collect() } async fn exists(&self, id_or_name: &str) -> BoxliteResult { @@ -403,7 +400,7 @@ mod tests { let mut requests = Vec::new(); for body in [ r#"{"capabilities":{"linux_capabilities_enabled":true}}"#, - r#"{"box_id":"01HJK4TNRPQSXYZ8WM6NCVT9R5","name":null,"status":"configured","created_at":"2026-01-01T00:00:00Z","updated_at":"2026-01-01T00:00:00Z","pid":null,"image":"alpine:latest","cpus":2,"memory_mib":512,"advanced":{"capabilities":{"add":["SYS_ADMIN"],"drop":[]}},"labels":{}}"#, + r#"{"box_id":"01HJK4TNRPQSXYZ8WM6NCVT9R5","name":null,"status":"configured","created_at":"2026-01-01T00:00:00Z","updated_at":"2026-01-01T00:00:00Z","pid":null,"image":"alpine:latest","cpus":2,"memory_mib":512,"labels":{}}"#, ] { let (mut socket, _) = listener.accept().await.unwrap(); let mut headers = Vec::new(); @@ -456,7 +453,7 @@ mod tests { async fn strict_create_does_not_recheck_server_options() { let (port, server) = json_server(vec![ r#"{"capabilities":{"linux_capabilities_enabled":true}}"#, - r#"{"box_id":"01HJK4TNRPQSXYZ8WM6NCVT9R5","name":null,"status":"configured","created_at":"2026-01-01T00:00:00Z","updated_at":"2026-01-01T00:00:00Z","pid":null,"image":"alpine:latest","cpus":2,"memory_mib":512,"advanced":{"capabilities":{"add":["SYS_ADMIN"],"drop":[]}},"labels":{}}"#, + r#"{"box_id":"01HJK4TNRPQSXYZ8WM6NCVT9R5","name":null,"status":"configured","created_at":"2026-01-01T00:00:00Z","updated_at":"2026-01-01T00:00:00Z","pid":null,"image":"alpine:latest","cpus":2,"memory_mib":512,"labels":{}}"#, ]) .await; let runtime = @@ -575,7 +572,7 @@ mod tests { #[tokio::test] async fn get_or_create_does_not_recheck_server_options() { let (port, server) = json_server(vec![ - r#"{"box_info":{"box_id":"01HJK4TNRPQSXYZ8WM6NCVT9R5","name":"named","status":"configured","created_at":"2026-01-01T00:00:00Z","updated_at":"2026-01-01T00:00:00Z","pid":null,"image":"alpine:latest","cpus":2,"memory_mib":512,"advanced":{"capabilities":{"add":["SYS_ADMIN"],"drop":[]}},"labels":{}},"created":false}"#, + r#"{"box_info":{"box_id":"01HJK4TNRPQSXYZ8WM6NCVT9R5","name":"named","status":"configured","created_at":"2026-01-01T00:00:00Z","updated_at":"2026-01-01T00:00:00Z","pid":null,"image":"alpine:latest","cpus":2,"memory_mib":512,"labels":{}},"created":false}"#, ]) .await; let runtime = @@ -594,56 +591,4 @@ mod tests { ["POST /v1/boxes/get-or-create/strict HTTP/1.1"] ); } - - #[tokio::test] - async fn authoritative_inspection_rejects_responses_without_capability_policy() { - let listener = TcpListener::bind(("127.0.0.1", 0)).await.unwrap(); - let port = listener.local_addr().unwrap().port(); - let server = tokio::spawn(async move { - let box_json = r#"{"box_id":"01HJK4TNRPQSXYZ8WM6NCVT9R5","name":"named","status":"configured","created_at":"2026-01-01T00:00:00Z","updated_at":"2026-01-01T00:00:00Z","pid":null,"image":"alpine:latest","cpus":2,"memory_mib":512,"labels":{}}"#; - let list_json = format!(r#"{{"boxes":[{box_json}],"next_page_token":null}}"#); - let bodies = [box_json.to_string(), box_json.to_string(), list_json]; - let mut requests = Vec::new(); - - for body in bodies { - let (mut socket, _) = listener.accept().await.unwrap(); - let mut headers = Vec::new(); - while !headers.ends_with(b"\r\n\r\n") { - headers.push(socket.read_u8().await.unwrap()); - } - let request = String::from_utf8(headers).unwrap(); - requests.push(request.lines().next().unwrap().to_string()); - socket - .write_all( - format!( - "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}", - body.len(), - body - ) - .as_bytes(), - ) - .await - .unwrap(); - } - requests - }); - - let runtime = - RestRuntime::new(&BoxliteRestOptions::new(format!("http://127.0.0.1:{port}"))).unwrap(); - let get_result = RuntimeBackend::get(&runtime, "named").await; - let get_info_result = RuntimeBackend::get_info(&runtime, "named").await; - let list_result = RuntimeBackend::list_info(&runtime).await; - - assert!(matches!(get_result, Err(BoxliteError::Unsupported(_)))); - assert!(matches!(get_info_result, Err(BoxliteError::Unsupported(_)))); - assert!(matches!(list_result, Err(BoxliteError::Unsupported(_)))); - assert_eq!( - server.await.unwrap(), - [ - "GET /v1/boxes/named/strict HTTP/1.1", - "GET /v1/boxes/named/strict HTTP/1.1", - "GET /v1/boxes/strict HTTP/1.1", - ] - ); - } } diff --git a/src/boxlite/src/rest/types.rs b/src/boxlite/src/rest/types.rs index b12356304..7ef05781b 100644 --- a/src/boxlite/src/rest/types.rs +++ b/src/boxlite/src/rest/types.rs @@ -254,9 +254,6 @@ pub(crate) struct BoxResponse { pub image: String, pub cpus: u8, pub memory_mib: u32, - /// `None` means an older server omitted expert inspection metadata. - #[serde(default)] - pub advanced: Option, #[serde(default)] pub labels: HashMap, /// Absent while the box's main command is still running. An older server @@ -279,18 +276,6 @@ pub(crate) struct GetOrCreateBoxResponse { } impl BoxResponse { - pub(crate) fn to_authoritative_box_info( - &self, - ) -> boxlite_shared::errors::BoxliteResult { - if self.advanced.is_none() { - let box_name = self.name.as_deref().unwrap_or(&self.box_id); - return Err(BoxliteError::Unsupported(format!( - "REST server did not return an authoritative Linux capability policy for box {box_name}" - ))); - } - self.to_box_info() - } - pub fn to_box_info(&self) -> boxlite_shared::errors::BoxliteResult { use crate::runtime::id::BoxID; @@ -322,11 +307,6 @@ impl BoxResponse { image: self.image.clone(), cpus: self.cpus, memory_mib: self.memory_mib, - advanced: self - .advanced - .as_ref() - .map(BoxAdvancedResponse::to_core) - .unwrap_or_default(), labels: self.labels.clone(), auto_pause: self.auto_pause, auto_delete: self.auto_delete, @@ -337,32 +317,6 @@ impl BoxResponse { } } -#[derive(Debug, Deserialize)] -pub(crate) struct BoxAdvancedResponse { - pub capabilities: AuthoritativeContainerCapabilities, -} - -#[derive(Debug, Deserialize)] -pub(crate) struct AuthoritativeContainerCapabilities { - pub add: Vec, - pub drop: Vec, -} - -impl BoxAdvancedResponse { - pub(crate) fn capability_policy(&self) -> ContainerCapabilities { - ContainerCapabilities { - add: self.capabilities.add.clone(), - drop: self.capabilities.drop.clone(), - } - } - - fn to_core(&self) -> crate::runtime::types::BoxAdvancedInfo { - crate::runtime::types::BoxAdvancedInfo { - capabilities: self.capability_policy(), - } - } -} - fn default_auto_pause() -> u32 { 900 } @@ -851,12 +805,6 @@ mod tests { image: "python:3.11".to_string(), cpus: 2, memory_mib: 512, - advanced: Some(BoxAdvancedResponse { - capabilities: AuthoritativeContainerCapabilities { - add: vec!["SYS_ADMIN".into()], - drop: vec!["NET_RAW".into()], - }, - }), labels: HashMap::new(), exit_code: None, auto_pause: 1800, @@ -868,8 +816,6 @@ mod tests { assert_eq!(info.image, "python:3.11"); assert_eq!(info.cpus, 2); assert_eq!(info.memory_mib, 512); - assert_eq!(info.advanced.capabilities.add, ["SYS_ADMIN"]); - assert_eq!(info.advanced.capabilities.drop, ["NET_RAW"]); assert_eq!(info.auto_pause, 1800); assert_eq!(info.auto_delete, 604800); } @@ -888,12 +834,6 @@ mod tests { image: "alpine:latest".to_string(), cpus: 1, memory_mib: 256, - advanced: Some(BoxAdvancedResponse { - capabilities: AuthoritativeContainerCapabilities { - add: Vec::new(), - drop: Vec::new(), - }, - }), labels: HashMap::new(), exit_code: None, auto_pause: 900, @@ -922,12 +862,6 @@ mod tests { image: "alpine:latest".to_string(), cpus: 1, memory_mib: 256, - advanced: Some(BoxAdvancedResponse { - capabilities: AuthoritativeContainerCapabilities { - add: Vec::new(), - drop: Vec::new(), - }, - }), labels: HashMap::new(), exit_code: None, auto_pause: 900, @@ -1000,12 +934,6 @@ mod tests { image: "python:3.11".to_string(), cpus: 2, memory_mib: 512, - advanced: Some(BoxAdvancedResponse { - capabilities: AuthoritativeContainerCapabilities { - add: Vec::new(), - drop: Vec::new(), - }, - }), labels: HashMap::new(), exit_code: None, auto_pause: 900, diff --git a/src/boxlite/src/runtime/types.rs b/src/boxlite/src/runtime/types.rs index b6285a78b..2159c442f 100644 --- a/src/boxlite/src/runtime/types.rs +++ b/src/boxlite/src/runtime/types.rs @@ -9,7 +9,6 @@ use std::fmt; use std::hash::Hash; pub use crate::litebox::{BoxState, BoxStatus, HealthStatus}; -use crate::runtime::advanced_options::ContainerCapabilities; use crate::runtime::id::BoxID; /// Re-exported here so the CLI can reach volume metadata the same way it /// reaches [`ImageInfo`] (`boxlite::runtime::types::VolumeInfo`). The type @@ -307,17 +306,6 @@ impl BoxLifecyclePolicy { } } -/// Expert-only public metadata about a box. -/// -/// This intentionally contains inspection-safe state only. Runtime security -/// configuration remains private to the owning runtime. -#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] -#[serde(default)] -pub struct BoxAdvancedInfo { - /// Linux capability policy applied to the container process. - pub capabilities: ContainerCapabilities, -} - /// Public metadata about a box (returned by list operations). #[derive(Debug, Clone, Serialize, Deserialize)] pub struct BoxInfo { @@ -348,10 +336,6 @@ pub struct BoxInfo { /// Allocated memory in MiB. pub memory_mib: u32, - /// Expert-only inspection metadata. - #[serde(default)] - pub advanced: BoxAdvancedInfo, - /// User-defined labels for filtering and organization. pub labels: HashMap, @@ -391,9 +375,6 @@ impl BoxInfo { }, cpus: config.options.cpus.unwrap_or(DEFAULT_CPUS), memory_mib: config.options.memory_mib.unwrap_or(DEFAULT_MEMORY_MIB), - advanced: BoxAdvancedInfo { - capabilities: config.options.advanced.capabilities.clone(), - }, labels: HashMap::new(), // Local runtimes do not sweep lifecycle deadlines, but metadata keeps // the configured values so callers can inspect the effective policy. @@ -415,7 +396,6 @@ impl PartialEq for BoxInfo { && self.image == other.image && self.cpus == other.cpus && self.memory_mib == other.memory_mib - && self.advanced == other.advanced && self.labels == other.labels && self.auto_pause == other.auto_pause && self.auto_delete == other.auto_delete diff --git a/src/cli/src/commands/inspect.rs b/src/cli/src/commands/inspect.rs index 6b18392fa..ff5f78513 100644 --- a/src/cli/src/commands/inspect.rs +++ b/src/cli/src/commands/inspect.rs @@ -41,22 +41,6 @@ struct InspectPresenter { cpus: u8, #[serde(rename = "Memory")] memory: u64, - #[serde(rename = "Advanced")] - advanced: InspectAdvancedPresenter, -} - -#[derive(Debug, Serialize)] -struct InspectAdvancedPresenter { - #[serde(rename = "Capabilities")] - capabilities: InspectCapabilitiesPresenter, -} - -#[derive(Debug, Serialize)] -struct InspectCapabilitiesPresenter { - #[serde(rename = "Add")] - add: Vec, - #[serde(rename = "Drop")] - drop: Vec, } #[derive(Debug, Serialize)] @@ -89,12 +73,6 @@ impl From<&BoxInfo> for InspectPresenter { }, cpus: info.cpus, memory: info.memory_mib as u64 * 1024 * 1024, - advanced: InspectAdvancedPresenter { - capabilities: InspectCapabilitiesPresenter { - add: info.advanced.capabilities.add.clone(), - drop: info.advanced.capabilities.drop.clone(), - }, - }, } } } @@ -254,7 +232,7 @@ mod tests { use std::collections::HashMap; #[test] - fn inspect_serializes_capability_policy() { + fn inspect_omits_advanced_capability_metadata() { let now = chrono::Utc::now(); let info = BoxInfo { id: BoxID::parse("inspect-capabilities").unwrap(), @@ -266,12 +244,6 @@ mod tests { image: "alpine:latest".into(), cpus: 1, memory_mib: 512, - advanced: boxlite::BoxAdvancedInfo { - capabilities: boxlite::ContainerCapabilities { - add: vec!["SYS_ADMIN".into()], - drop: vec!["NET_RAW".into()], - }, - }, labels: HashMap::new(), auto_pause: 0, auto_delete: 0, @@ -281,13 +253,6 @@ mod tests { }; let value = serde_json::to_value(InspectPresenter::from(&info)).unwrap(); - assert_eq!( - value["Advanced"]["Capabilities"]["Add"], - serde_json::json!(["SYS_ADMIN"]) - ); - assert_eq!( - value["Advanced"]["Capabilities"]["Drop"], - serde_json::json!(["NET_RAW"]) - ); + assert!(value.get("Advanced").is_none()); } } diff --git a/src/cli/src/commands/serve/mod.rs b/src/cli/src/commands/serve/mod.rs index c45184fbf..b171202b4 100644 --- a/src/cli/src/commands/serve/mod.rs +++ b/src/cli/src/commands/serve/mod.rs @@ -709,12 +709,6 @@ fn box_info_to_response(info: &BoxInfo) -> BoxResponse { image: info.image.clone(), cpus: info.cpus, memory_mib: info.memory_mib, - advanced: types::BoxAdvancedResponse { - capabilities: types::ContainerCapabilitiesResponse { - add: info.advanced.capabilities.add.clone(), - drop: info.advanced.capabilities.drop.clone(), - }, - }, labels: info.labels.clone(), auto_pause: info.auto_pause, auto_delete: info.auto_delete, @@ -1101,7 +1095,7 @@ fn build_router(state: Arc) -> Router { ) .route( "/v1/boxes/strict", - post(boxes::create_box).get(boxes::list_boxes), + post(boxes::create_box), ) .route( "/v1/boxes", @@ -1113,7 +1107,6 @@ fn build_router(state: Arc) -> Router { .delete(boxes::remove_box) .head(boxes::head_box), ) - .route("/v1/boxes/{box_id}/strict", get(boxes::get_box)) // Box lifecycle .route( "/v1/boxes/{box_id}/start", diff --git a/src/cli/src/commands/serve/types.rs b/src/cli/src/commands/serve/types.rs index f3c003ad4..85d6bab37 100644 --- a/src/cli/src/commands/serve/types.rs +++ b/src/cli/src/commands/serve/types.rs @@ -115,7 +115,6 @@ pub(super) struct BoxResponse { pub image: String, pub cpus: u8, pub memory_mib: u32, - pub advanced: BoxAdvancedResponse, pub labels: HashMap, pub auto_pause: u32, pub auto_delete: u32, @@ -133,17 +132,6 @@ pub(super) struct GetOrCreateBoxResponse { pub created: bool, } -#[derive(Serialize)] -pub(super) struct BoxAdvancedResponse { - pub capabilities: ContainerCapabilitiesResponse, -} - -#[derive(Serialize)] -pub(super) struct ContainerCapabilitiesResponse { - pub add: Vec, - pub drop: Vec, -} - #[derive(Serialize)] pub(super) struct ListBoxesResponse { pub boxes: Vec, From 88e861c7f15328732d595762c4f045fe5e4366f3 Mon Sep 17 00:00:00 2001 From: tester Date: Mon, 27 Jul 2026 17:05:34 +0800 Subject: [PATCH 08/10] refactor: drop parallel contracts around capability policy Capability support is negotiated by the `linux_capabilities_enabled` config flag, the guest's `linux-capabilities-v2` Ping feature, and runner feature advertisement. The `/strict` routes and the `*_WITH_CAPABILITIES_V2` job types restated that negotiation alongside it, so remove them and keep one contract. Four things those routes did add, and where each goes: - Unknown-field rejection. The cloud capability DTOs now carry it directly, so a misspelled `advanced.capabilites` is refused there rather than accepted and ignored, which would otherwise start the box with the default capability set while the caller believed a policy applied. Not replaced: unknown top-level fields, the runner's strict decoder, and `deny_unknown_fields` on the core `BoxOptions` tree. `boxlite serve` keeps its own on the wire types. - A 404 from an API build predating the field. Given up: these routes never shipped, so nothing depends on the signal. A rolling deploy can still answer from an older task. - Server-side atomic get-or-create with a reuse compatibility check. The REST path returns to the client-side get-then-create on main; the local runtime keeps its own check when adopting a box. - A create schema without the client-controlled `security` preset. The merged schema keeps `security` where main has it and adds `advanced` beside it; `boxlite serve` still refuses `security`. Cover the inspection routes with a test: addressing a versioned variant there 404s, and `get` maps NotFound to `Ok(None)`, so an existing box reads as missing instead of failing loudly. Simplify the rest of the policy surface: drop the no-op v8-to-v9 schema bump, the flat `capAdd`/`cap_add` fields that never shipped, and the cloud rejection of rootfs_path/tty/secrets, which was never about capabilities; collapse the two host capability-name canonicalizers into one, leaving the guest's own untouched; keep `BoxOptions::sanitize` under the name main ships; and move `ContainerConfig.advanced` to proto field 6 rather than 8. Keep the export-side archive v4 stamp: a pre-capability importer accepts up to v3 and would otherwise drop the policy, starting the box with wider privileges than the archive asked for. Import still rejects a too-new manifest; only the check that a policy-bearing manifest must claim v4 is gone, since a forged manifest can claim any version. The runner feature check for an existing box reads through the TTL-cached runner lookup again rather than a dedicated uncached one. Replace the feature-named guest test target with a generic `test:unit:guest` so capability resolution and OCI spec construction stay covered in CI. Move the network FFI ownership docs into `sdks/c/src/network.rs` so cbindgen emits them, and regenerate the API clients. That also unpins `apps/api-client-go/api/openapi.yaml` and drops the hand-written `contracttest/` package that guarded the runner `features` field. --- .github/workflows/test.yml | 4 +- apps/api-client-go/.openapi-generator-ignore | 4 - apps/api-client-go/.openapi-generator/FILES | 1 + apps/api-client-go/api/openapi.yaml | 9 + .../model_runner_healthcheck_test.go | 38 -- apps/api-client-go/model_box.go | 10 +- apps/api-client-go/model_job_type.go | 5 +- .../api-client-go/model_runner_healthcheck.go | 81 ++-- apps/api-client-go/project.json | 3 +- .../src/box/common/box-advanced-options.ts | 4 - apps/api/src/box/dto/create-box.dto.ts | 7 +- apps/api/src/box/dto/job-type-map.dto.ts | 6 - apps/api/src/box/enums/job-type.enum.ts | 2 - .../box-actions/box-start.action.spec.ts | 20 +- .../managers/box-actions/box-start.action.ts | 4 +- .../runner-adapter/runnerAdapter.v0.spec.ts | 31 +- .../box/runner-adapter/runnerAdapter.v0.ts | 20 +- .../runner-adapter/runnerAdapter.v2.spec.ts | 29 +- .../box/runner-adapter/runnerAdapter.v2.ts | 27 +- apps/api/src/box/services/box.service.spec.ts | 132 +------ apps/api/src/box/services/box.service.ts | 91 +---- .../box/services/job-state-handler.service.ts | 2 - apps/api/src/box/services/runner.service.ts | 9 - .../box/utils/capability-validation.util.ts | 30 +- .../boxlite-rest/boxlite-box.controller.ts | 115 +----- .../boxlite-rest/boxlite-rest-routing.spec.ts | 105 +----- .../src/boxlite-rest/dto/box-response.dto.ts | 9 - .../boxlite-rest/dto/create-box.dto.spec.ts | 36 +- .../src/boxlite-rest/dto/create-box.dto.ts | 33 +- .../mappers/box-to-box.mapper.spec.ts | 20 - .../boxlite-rest/mappers/box-to-box.mapper.ts | 12 - apps/libs/api-client/src/docs/JobType.md | 4 - .../api-client/src/docs/RunnerHealthcheck.md | 2 + apps/libs/api-client/src/models/box.ts | 255 ++++++------- apps/libs/api-client/src/models/job-type.ts | 4 +- .../src/models/runner-healthcheck.ts | 62 +-- .../src/.openapi-generator/FILES | 4 - .../libs/runner-api-client/src/api/box-api.ts | 153 -------- .../libs/runner-api-client/src/docs/BoxApi.md | 119 ------ .../src/docs/CreateBoxDTO.md | 1 + .../src/docs/CreateBoxWithCapabilitiesDTO.md | 60 --- .../src/docs/RecoverBoxDTO.md | 1 + .../src/docs/RecoverBoxWithCapabilitiesDTO.md | 42 --- .../src/models/create-box-dto.ts | 4 + .../create-box-with-capabilities-dto.ts | 51 --- .../runner-api-client/src/models/index.ts | 2 - .../src/models/recover-box-dto.ts | 4 + .../recover-box-with-capabilities-dto.ts | 36 -- apps/runner/pkg/api/controllers/box.go | 226 +++-------- .../api/controllers/box_capabilities_test.go | 231 ------------ apps/runner/pkg/api/docs/docs.go | 289 +------------- apps/runner/pkg/api/docs/swagger.json | 270 +------------ apps/runner/pkg/api/docs/swagger.yaml | 197 +--------- apps/runner/pkg/api/dto/box.go | 111 +----- .../pkg/api/dto/box_capabilities_test.go | 133 ++----- apps/runner/pkg/api/server.go | 2 - apps/runner/pkg/boxlite/stubs.go | 2 +- apps/runner/pkg/runner/v2/executor/box.go | 96 +---- .../v2/executor/box_capabilities_test.go | 275 ++------------ .../runner/pkg/runner/v2/executor/executor.go | 4 - docs/architecture/container-capabilities.md | 71 ++-- make/test.mk | 20 +- openapi/box.openapi.yaml | 112 +----- openapi/reference-server/server.py | 50 +-- .../tests/test_handle_cache.py | 11 +- sdks/c/include/boxlite.h | 58 ++- sdks/c/src/advanced_options.rs | 2 +- sdks/c/src/network.rs | 24 ++ sdks/c/src/tests.rs | 4 +- sdks/go/runtime.go | 5 +- sdks/node/src/runtime.rs | 5 +- sdks/python/boxlite/sync_api/_boxlite.py | 5 +- src/boxlite/src/db/migration/mod.rs | 2 - src/boxlite/src/db/migration/v8_to_v9.rs | 34 -- src/boxlite/src/db/mod.rs | 29 -- src/boxlite/src/db/schema.rs | 2 +- src/boxlite/src/litebox/archive.rs | 33 +- src/boxlite/src/litebox/init/mod.rs | 2 +- src/boxlite/src/rest/runtime.rs | 355 ++++-------------- src/boxlite/src/rest/types.rs | 6 - src/boxlite/src/runtime/advanced_options.rs | 38 +- src/boxlite/src/runtime/core.rs | 4 +- src/boxlite/src/runtime/import.rs | 46 +-- src/boxlite/src/runtime/options.rs | 69 +--- src/boxlite/src/runtime/rt_impl.rs | 6 +- src/cli/src/commands/serve/handlers/boxes.rs | 56 +-- src/cli/src/commands/serve/mod.rs | 10 +- src/cli/src/commands/serve/types.rs | 35 +- src/shared/proto/boxlite/v1/service.proto | 2 +- 89 files changed, 748 insertions(+), 3887 deletions(-) delete mode 100644 apps/api-client-go/contracttest/model_runner_healthcheck_test.go delete mode 100644 apps/libs/runner-api-client/src/docs/CreateBoxWithCapabilitiesDTO.md delete mode 100644 apps/libs/runner-api-client/src/docs/RecoverBoxWithCapabilitiesDTO.md delete mode 100644 apps/libs/runner-api-client/src/models/create-box-with-capabilities-dto.ts delete mode 100644 apps/libs/runner-api-client/src/models/recover-box-with-capabilities-dto.ts delete mode 100644 apps/runner/pkg/api/controllers/box_capabilities_test.go delete mode 100644 src/boxlite/src/db/migration/v8_to_v9.rs diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 7c1c9fc66..f8230eed8 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -117,9 +117,9 @@ jobs: # Only test boxlite-shared - boxlite requires libkrun/libgvproxy not available in CI run: cargo llvm-cov nextest -p boxlite-shared --lib --profile ci --lcov --output-path lcov.info - - name: Run guest capability policy tests + - name: Run guest unit tests if: runner.os == 'Linux' - run: make test:unit:guest-capabilities + run: make test:unit:guest - name: Upload coverage to Codecov uses: codecov/codecov-action@v5 diff --git a/apps/api-client-go/.openapi-generator-ignore b/apps/api-client-go/.openapi-generator-ignore index e8d83c8b6..da5f36200 100644 --- a/apps/api-client-go/.openapi-generator-ignore +++ b/apps/api-client-go/.openapi-generator-ignore @@ -28,7 +28,3 @@ test/* .gitlab-ci.yml docs/* README.md - -# Deprecated snapshot. The NestJS export is authoritative; keep this file -# unchanged until it is removed from the repository. -api/openapi.yaml diff --git a/apps/api-client-go/.openapi-generator/FILES b/apps/api-client-go/.openapi-generator/FILES index ab88c087e..c8e5ffcc9 100644 --- a/apps/api-client-go/.openapi-generator/FILES +++ b/apps/api-client-go/.openapi-generator/FILES @@ -1,4 +1,5 @@ .gitignore +api/openapi.yaml api_admin.go api_api_keys.go api_audit.go diff --git a/apps/api-client-go/api/openapi.yaml b/apps/api-client-go/api/openapi.yaml index e18d88137..ac146a67c 100644 --- a/apps/api-client-go/api/openapi.yaml +++ b/apps/api-client-go/api/openapi.yaml @@ -7249,6 +7249,8 @@ components: proxyUrl: http://proxy.boxlite.example.com:8080 apiUrl: http://api.boxlite.example.com:8080 appVersion: v0.0.0-dev + features: + - linux-capabilities-v2 properties: metrics: allOf: @@ -7275,6 +7277,13 @@ components: description: Runner app version example: v0.0.0-dev type: string + features: + description: Optional runner features used for rollout negotiation + example: + - linux-capabilities-v2 + items: + type: string + type: array required: - appVersion type: object diff --git a/apps/api-client-go/contracttest/model_runner_healthcheck_test.go b/apps/api-client-go/contracttest/model_runner_healthcheck_test.go deleted file mode 100644 index aeed138d3..000000000 --- a/apps/api-client-go/contracttest/model_runner_healthcheck_test.go +++ /dev/null @@ -1,38 +0,0 @@ -package contracttest - -import ( - "encoding/json" - "reflect" - "testing" - - apiclient "github.com/boxlite-ai/boxlite/libs/api-client-go" -) - -func TestRunnerHealthcheckCarriesAdvertisedFeatures(t *testing.T) { - healthcheck := apiclient.NewRunnerHealthcheck("v1.0.0") - healthcheck.SetFeatures([]string{"linux-capabilities-v2"}) - - payload, err := json.Marshal(healthcheck) - if err != nil { - t.Fatalf("marshal healthcheck: %v", err) - } - - var wire map[string]any - if err := json.Unmarshal(payload, &wire); err != nil { - t.Fatalf("decode healthcheck: %v", err) - } - if !reflect.DeepEqual(wire["features"], []any{"linux-capabilities-v2"}) { - t.Fatalf("features lost from healthcheck: %s", payload) - } -} - -func TestRunnerHealthcheckDoesNotTreatFeaturesAsAdditional(t *testing.T) { - var healthcheck apiclient.RunnerHealthcheck - if err := json.Unmarshal([]byte(`{"appVersion":"v1.0.0","features":["linux-capabilities-v2"]}`), &healthcheck); err != nil { - t.Fatalf("unmarshal healthcheck: %v", err) - } - - if _, exists := healthcheck.AdditionalProperties["features"]; exists { - t.Fatal("known features field must not remain in AdditionalProperties") - } -} diff --git a/apps/api-client-go/model_box.go b/apps/api-client-go/model_box.go index fc93ec2a5..87053b3a7 100644 --- a/apps/api-client-go/model_box.go +++ b/apps/api-client-go/model_box.go @@ -79,7 +79,7 @@ type Box struct { // The runner ID of the box RunnerId *string `json:"runnerId,omitempty"` // The toolbox proxy URL for the box - ToolboxProxyUrl string `json:"toolboxProxyUrl"` + ToolboxProxyUrl string `json:"toolboxProxyUrl"` AdditionalProperties map[string]interface{} } @@ -936,7 +936,7 @@ func (o *Box) SetToolboxProxyUrl(v string) { } func (o Box) MarshalJSON() ([]byte, error) { - toSerialize, err := o.ToMap() + toSerialize,err := o.ToMap() if err != nil { return []byte{}, err } @@ -1038,10 +1038,10 @@ func (o *Box) UnmarshalJSON(data []byte) (err error) { err = json.Unmarshal(data, &allProperties) if err != nil { - return err + return err; } - for _, requiredProperty := range requiredProperties { + for _, requiredProperty := range(requiredProperties) { if _, exists := allProperties[requiredProperty]; !exists { return fmt.Errorf("no value given for required property %v", requiredProperty) } @@ -1130,3 +1130,5 @@ func (v *NullableBox) UnmarshalJSON(src []byte) error { v.isSet = true return json.Unmarshal(src, &v.value) } + + diff --git a/apps/api-client-go/model_job_type.go b/apps/api-client-go/model_job_type.go index c2d695a6b..530dccd43 100644 --- a/apps/api-client-go/model_job_type.go +++ b/apps/api-client-go/model_job_type.go @@ -21,7 +21,6 @@ type JobType string // List of JobType const ( JOBTYPE_CREATE_BOX JobType = "CREATE_BOX" - JOBTYPE_CREATE_BOX_WITH_CAPABILITIES_V2 JobType = "CREATE_BOX_WITH_CAPABILITIES_V2" JOBTYPE_START_BOX JobType = "START_BOX" JOBTYPE_STOP_BOX JobType = "STOP_BOX" JOBTYPE_DESTROY_BOX JobType = "DESTROY_BOX" @@ -29,7 +28,6 @@ const ( JOBTYPE_CREATE_BACKUP JobType = "CREATE_BACKUP" JOBTYPE_PULL_ARTIFACT JobType = "PULL_ARTIFACT" JOBTYPE_RECOVER_BOX JobType = "RECOVER_BOX" - JOBTYPE_RECOVER_BOX_WITH_CAPABILITIES_V2 JobType = "RECOVER_BOX_WITH_CAPABILITIES_V2" JOBTYPE_INSPECT_ARTIFACT_IN_REGISTRY JobType = "INSPECT_ARTIFACT_IN_REGISTRY" JOBTYPE_REMOVE_ARTIFACT JobType = "REMOVE_ARTIFACT" JOBTYPE_UPDATE_BOX_NETWORK_SETTINGS JobType = "UPDATE_BOX_NETWORK_SETTINGS" @@ -39,7 +37,6 @@ const ( // All allowed values of JobType enum var AllowedJobTypeEnumValues = []JobType{ "CREATE_BOX", - "CREATE_BOX_WITH_CAPABILITIES_V2", "START_BOX", "STOP_BOX", "DESTROY_BOX", @@ -47,7 +44,6 @@ var AllowedJobTypeEnumValues = []JobType{ "CREATE_BACKUP", "PULL_ARTIFACT", "RECOVER_BOX", - "RECOVER_BOX_WITH_CAPABILITIES_V2", "INSPECT_ARTIFACT_IN_REGISTRY", "REMOVE_ARTIFACT", "UPDATE_BOX_NETWORK_SETTINGS", @@ -134,3 +130,4 @@ func (v *NullableJobType) UnmarshalJSON(src []byte) error { v.isSet = true return json.Unmarshal(src, &v.value) } + diff --git a/apps/api-client-go/model_runner_healthcheck.go b/apps/api-client-go/model_runner_healthcheck.go index c67c456f4..1bfd5b675 100644 --- a/apps/api-client-go/model_runner_healthcheck.go +++ b/apps/api-client-go/model_runner_healthcheck.go @@ -21,8 +21,6 @@ var _ MappedNullable = &RunnerHealthcheck{} // RunnerHealthcheck struct for RunnerHealthcheck type RunnerHealthcheck struct { - // Optional runner features used for rollout negotiation - Features []string `json:"features,omitempty"` // Runner metrics Metrics *RunnerHealthMetrics `json:"metrics,omitempty"` // Health status of individual services on the runner @@ -34,7 +32,9 @@ type RunnerHealthcheck struct { // Runner API URL ApiUrl *string `json:"apiUrl,omitempty"` // Runner app version - AppVersion string `json:"appVersion"` + AppVersion string `json:"appVersion"` + // Optional runner features used for rollout negotiation + Features []string `json:"features,omitempty"` AdditionalProperties map[string]interface{} } @@ -58,33 +58,6 @@ func NewRunnerHealthcheckWithDefaults() *RunnerHealthcheck { return &this } -// GetFeatures returns the Features field value if set, zero value otherwise. -func (o *RunnerHealthcheck) GetFeatures() []string { - if o == nil || IsNil(o.Features) { - var ret []string - return ret - } - return o.Features -} - -// GetFeaturesOk returns a tuple with the Features field value if set, nil otherwise. -func (o *RunnerHealthcheck) GetFeaturesOk() ([]string, bool) { - if o == nil || IsNil(o.Features) { - return nil, false - } - return o.Features, true -} - -// HasFeatures returns true if Features has been set. -func (o *RunnerHealthcheck) HasFeatures() bool { - return o != nil && !IsNil(o.Features) -} - -// SetFeatures sets the Features field value. -func (o *RunnerHealthcheck) SetFeatures(v []string) { - o.Features = v -} - // GetMetrics returns the Metrics field value if set, zero value otherwise. func (o *RunnerHealthcheck) GetMetrics() RunnerHealthMetrics { if o == nil || IsNil(o.Metrics) { @@ -269,8 +242,40 @@ func (o *RunnerHealthcheck) SetAppVersion(v string) { o.AppVersion = v } +// GetFeatures returns the Features field value if set, zero value otherwise. +func (o *RunnerHealthcheck) GetFeatures() []string { + if o == nil || IsNil(o.Features) { + var ret []string + return ret + } + return o.Features +} + +// GetFeaturesOk returns a tuple with the Features field value if set, nil otherwise +// and a boolean to check if the value has been set. +func (o *RunnerHealthcheck) GetFeaturesOk() ([]string, bool) { + if o == nil || IsNil(o.Features) { + return nil, false + } + return o.Features, true +} + +// HasFeatures returns a boolean if a field has been set. +func (o *RunnerHealthcheck) HasFeatures() bool { + if o != nil && !IsNil(o.Features) { + return true + } + + return false +} + +// SetFeatures gets a reference to the given []string and assigns it to the Features field. +func (o *RunnerHealthcheck) SetFeatures(v []string) { + o.Features = v +} + func (o RunnerHealthcheck) MarshalJSON() ([]byte, error) { - toSerialize, err := o.ToMap() + toSerialize,err := o.ToMap() if err != nil { return []byte{}, err } @@ -279,9 +284,6 @@ func (o RunnerHealthcheck) MarshalJSON() ([]byte, error) { func (o RunnerHealthcheck) ToMap() (map[string]interface{}, error) { toSerialize := map[string]interface{}{} - if !IsNil(o.Features) { - toSerialize["features"] = o.Features - } if !IsNil(o.Metrics) { toSerialize["metrics"] = o.Metrics } @@ -298,6 +300,9 @@ func (o RunnerHealthcheck) ToMap() (map[string]interface{}, error) { toSerialize["apiUrl"] = o.ApiUrl } toSerialize["appVersion"] = o.AppVersion + if !IsNil(o.Features) { + toSerialize["features"] = o.Features + } for key, value := range o.AdditionalProperties { toSerialize[key] = value @@ -319,10 +324,10 @@ func (o *RunnerHealthcheck) UnmarshalJSON(data []byte) (err error) { err = json.Unmarshal(data, &allProperties) if err != nil { - return err + return err; } - for _, requiredProperty := range requiredProperties { + for _, requiredProperty := range(requiredProperties) { if _, exists := allProperties[requiredProperty]; !exists { return fmt.Errorf("no value given for required property %v", requiredProperty) } @@ -341,13 +346,13 @@ func (o *RunnerHealthcheck) UnmarshalJSON(data []byte) (err error) { additionalProperties := make(map[string]interface{}) if err = json.Unmarshal(data, &additionalProperties); err == nil { - delete(additionalProperties, "features") delete(additionalProperties, "metrics") delete(additionalProperties, "serviceHealth") delete(additionalProperties, "domain") delete(additionalProperties, "proxyUrl") delete(additionalProperties, "apiUrl") delete(additionalProperties, "appVersion") + delete(additionalProperties, "features") o.AdditionalProperties = additionalProperties } @@ -389,3 +394,5 @@ func (v *NullableRunnerHealthcheck) UnmarshalJSON(src []byte) error { v.isSet = true return json.Unmarshal(src, &v.value) } + + diff --git a/apps/api-client-go/project.json b/apps/api-client-go/project.json index 0c3970533..b6abf19bf 100644 --- a/apps/api-client-go/project.json +++ b/apps/api-client-go/project.json @@ -26,7 +26,8 @@ "outputs": [ "{projectRoot}/*.go", "{projectRoot}/go.mod", - "{projectRoot}/go.sum" + "{projectRoot}/go.sum", + "{projectRoot}/api/openapi.yaml" ], "options": { "commands": [ diff --git a/apps/api/src/box/common/box-advanced-options.ts b/apps/api/src/box/common/box-advanced-options.ts index 87eedb4ee..6a938d7a6 100644 --- a/apps/api/src/box/common/box-advanced-options.ts +++ b/apps/api/src/box/common/box-advanced-options.ts @@ -22,7 +22,3 @@ export function normalizeBoxAdvancedOptions( }, } } - -export function hasCapabilityPolicy(advanced?: BoxAdvancedOptions | null): boolean { - return !!(advanced?.capabilities.add.length || advanced?.capabilities.drop.length) -} diff --git a/apps/api/src/box/dto/create-box.dto.ts b/apps/api/src/box/dto/create-box.dto.ts index e8be2788d..bb6fd0484 100644 --- a/apps/api/src/box/dto/create-box.dto.ts +++ b/apps/api/src/box/dto/create-box.dto.ts @@ -22,7 +22,10 @@ import { Type } from 'class-transformer' import { ApiPropertyOptional, ApiSchema } from '@nestjs/swagger' import { BoxClass } from '../enums/box-class.enum' import { BoxVolume } from './box.dto' -import { IsLinuxCapabilityNameConstraint } from '../utils/capability-validation.util' +import { + HasNoUnknownCapabilityFieldsConstraint, + IsLinuxCapabilityNameConstraint, +} from '../utils/capability-validation.util' @ApiSchema({ name: 'CreateLinuxCapabilities' }) export class CreateLinuxCapabilitiesDto { @@ -55,6 +58,7 @@ export class CreateBoxAdvancedOptionsDto { @ValidateIf((_object, value) => value !== undefined) @IsObject() @ValidateNested() + @Validate(HasNoUnknownCapabilityFieldsConstraint, [['add', 'drop']]) @Type(() => CreateLinuxCapabilitiesDto) capabilities?: CreateLinuxCapabilitiesDto } @@ -100,6 +104,7 @@ export class CreateBoxDto { @ValidateIf((_object, value) => value !== undefined) @IsObject() @ValidateNested() + @Validate(HasNoUnknownCapabilityFieldsConstraint, [['capabilities']]) @Type(() => CreateBoxAdvancedOptionsDto) advanced?: CreateBoxAdvancedOptionsDto diff --git a/apps/api/src/box/dto/job-type-map.dto.ts b/apps/api/src/box/dto/job-type-map.dto.ts index bfe3e14c2..df29f2a59 100644 --- a/apps/api/src/box/dto/job-type-map.dto.ts +++ b/apps/api/src/box/dto/job-type-map.dto.ts @@ -16,9 +16,6 @@ export interface JobTypeMap { [JobType.CREATE_BOX]: { resourceType: [ResourceType.BOX] } - [JobType.CREATE_BOX_WITH_CAPABILITIES_V2]: { - resourceType: [ResourceType.BOX] - } [JobType.START_BOX]: { resourceType: [ResourceType.BOX] } @@ -49,9 +46,6 @@ export interface JobTypeMap { [JobType.RECOVER_BOX]: { resourceType: [ResourceType.BOX] } - [JobType.RECOVER_BOX_WITH_CAPABILITIES_V2]: { - resourceType: [ResourceType.BOX] - } } /** diff --git a/apps/api/src/box/enums/job-type.enum.ts b/apps/api/src/box/enums/job-type.enum.ts index ffee6e389..a4a4e2767 100644 --- a/apps/api/src/box/enums/job-type.enum.ts +++ b/apps/api/src/box/enums/job-type.enum.ts @@ -6,7 +6,6 @@ export enum JobType { CREATE_BOX = 'CREATE_BOX', - CREATE_BOX_WITH_CAPABILITIES_V2 = 'CREATE_BOX_WITH_CAPABILITIES_V2', START_BOX = 'START_BOX', STOP_BOX = 'STOP_BOX', DESTROY_BOX = 'DESTROY_BOX', @@ -14,7 +13,6 @@ export enum JobType { CREATE_BACKUP = 'CREATE_BACKUP', PULL_ARTIFACT = 'PULL_ARTIFACT', RECOVER_BOX = 'RECOVER_BOX', - RECOVER_BOX_WITH_CAPABILITIES_V2 = 'RECOVER_BOX_WITH_CAPABILITIES_V2', INSPECT_ARTIFACT_IN_REGISTRY = 'INSPECT_ARTIFACT_IN_REGISTRY', REMOVE_ARTIFACT = 'REMOVE_ARTIFACT', UPDATE_BOX_NETWORK_SETTINGS = 'UPDATE_BOX_NETWORK_SETTINGS', diff --git a/apps/api/src/box/managers/box-actions/box-start.action.spec.ts b/apps/api/src/box/managers/box-actions/box-start.action.spec.ts index 434e17cc7..d0d74abf3 100644 --- a/apps/api/src/box/managers/box-actions/box-start.action.spec.ts +++ b/apps/api/src/box/managers/box-actions/box-start.action.spec.ts @@ -30,10 +30,10 @@ describe('BoxStartAction.handleRunnerBoxStoppedStateOnDesiredStateStart', () => const ownRunner = { id: ownRunnerId, state: RunnerState.READY } as Runner - // findOneCurrentOrFail must return the runner that matches the requested id so we can + // findOneOrFail must return the runner that matches the requested id so we can // prove the action selected box.runnerId and nothing else. const runnerService = { - findOneCurrentOrFail: jest.fn(async (id: string) => { + findOneOrFail: jest.fn(async (id: string) => { if (id !== ownRunnerId) { throw new Error(`unexpected runner lookup: ${id}`) } @@ -81,8 +81,8 @@ describe('BoxStartAction.handleRunnerBoxStoppedStateOnDesiredStateStart', () => // The action started the box on its OWN runner, not a different one. expect(runnerUsedForStart?.id).toBe(ownRunnerId) expect(startBox).toHaveBeenCalledWith(box.id, box.authToken, expect.any(Object)) - // The current-state lookup was only ever asked about the box's own runner. - for (const call of runnerService.findOneCurrentOrFail.mock.calls) { + // findOneOrFail was only ever asked about the box's own runner. + for (const call of runnerService.findOneOrFail.mock.calls) { expect(call[0]).toBe(ownRunnerId) } expect(result).toBe(SYNC_AGAIN) @@ -96,7 +96,7 @@ describe('BoxStartAction.handleRunnerBoxStoppedStateOnDesiredStateStart', () => box.desiredState = BoxDesiredState.STARTED box.pending = true - const runnerService = { findOneCurrentOrFail: jest.fn() } + const runnerService = { findOneOrFail: jest.fn() } const runnerAdapterFactory = { create: jest.fn() } const lockCode = new LockCode('lock-2') const updatedFields: Partial[] = [] @@ -122,7 +122,7 @@ describe('BoxStartAction.handleRunnerBoxStoppedStateOnDesiredStateStart', () => await (action as BoxAction).run(box, lockCode) // No runner lookup or adapter creation: there is no runner to recover onto. - expect(runnerService.findOneCurrentOrFail).not.toHaveBeenCalled() + expect(runnerService.findOneOrFail).not.toHaveBeenCalled() expect(runnerAdapterFactory.create).not.toHaveBeenCalled() expect(updatedFields.some((u) => u.state === BoxState.ERROR)).toBe(true) }) @@ -137,7 +137,7 @@ describe('BoxStartAction.handleRunnerBoxStoppedStateOnDesiredStateStart', () => box.advanced = { capabilities: { add: [], drop: ['NET_RAW'] } } const runner = { id: runnerId, state: RunnerState.READY, features: [] } as Runner - const runnerService = { findOneCurrentOrFail: jest.fn(async () => runner) } + const runnerService = { findOneOrFail: jest.fn(async () => runner) } const runnerAdapterFactory = { create: jest.fn() } const lockCode = new LockCode('lock-capability-restart') const updatedFields: Partial[] = [] @@ -185,7 +185,7 @@ describe('BoxStartAction.handleRunnerBoxUnknownStateOnDesiredStateStart', () => box.pending = true const runner = { id: runnerId, state: RunnerState.READY } as Runner - const runnerService = { findOneCurrentOrFail: jest.fn(async () => runner) } + const runnerService = { findOneOrFail: jest.fn(async () => runner) } const createBox = jest.fn(async () => undefined) const runnerAdapterFactory = { create: jest.fn(async () => ({ createBox }) as any) } @@ -228,7 +228,7 @@ describe('BoxStartAction.handleRunnerBoxUnknownStateOnDesiredStateStart', () => box.pending = true const runner = { id: runnerId, state: RunnerState.READY } as Runner - const runnerService = { findOneCurrentOrFail: jest.fn(async () => runner) } + const runnerService = { findOneOrFail: jest.fn(async () => runner) } const createBox = jest.fn(async () => undefined) const runnerAdapterFactory = { create: jest.fn(async () => ({ createBox }) as any) } @@ -271,7 +271,7 @@ describe('BoxStartAction.handleRunnerBoxUnknownStateOnDesiredStateStart', () => box.advanced = { capabilities: { add: ['SYS_PTRACE'], drop: [] } } const runner = { id: runnerId, state: RunnerState.READY, features: [] } as Runner - const runnerService = { findOneCurrentOrFail: jest.fn(async () => runner) } + const runnerService = { findOneOrFail: jest.fn(async () => runner) } const runnerAdapterFactory = { create: jest.fn() } const lockCode = new LockCode('lock-capability-create') const updatedFields: Partial[] = [] diff --git a/apps/api/src/box/managers/box-actions/box-start.action.ts b/apps/api/src/box/managers/box-actions/box-start.action.ts index c0b2807c6..a6119fd30 100644 --- a/apps/api/src/box/managers/box-actions/box-start.action.ts +++ b/apps/api/src/box/managers/box-actions/box-start.action.ts @@ -59,7 +59,7 @@ export class BoxStartAction extends BoxAction { } private async handleRunnerBoxUnknownStateOnDesiredStateStart(box: Box, lockCode: LockCode): Promise { - const runner = await this.runnerService.findOneCurrentOrFail(box.runnerId) + const runner = await this.runnerService.findOneOrFail(box.runnerId) if (runner.state !== RunnerState.READY) { return DONT_SYNC_AGAIN } @@ -98,7 +98,7 @@ export class BoxStartAction extends BoxAction { return DONT_SYNC_AGAIN } - const runner = await this.runnerService.findOneCurrentOrFail(box.runnerId) + const runner = await this.runnerService.findOneOrFail(box.runnerId) if (runner.state !== RunnerState.READY) { return DONT_SYNC_AGAIN diff --git a/apps/api/src/box/runner-adapter/runnerAdapter.v0.spec.ts b/apps/api/src/box/runner-adapter/runnerAdapter.v0.spec.ts index e23be954d..ff45a3602 100644 --- a/apps/api/src/box/runner-adapter/runnerAdapter.v0.spec.ts +++ b/apps/api/src/box/runner-adapter/runnerAdapter.v0.spec.ts @@ -10,9 +10,7 @@ describe('RunnerAdapterV0 capability propagation', () => { function makeAdapter() { const boxApiClient = { create: jest.fn().mockResolvedValue({ data: {} }), - createWithCapabilities: jest.fn().mockResolvedValue({ data: {} }), recover: jest.fn().mockResolvedValue({ data: {} }), - recoverWithCapabilities: jest.fn().mockResolvedValue({ data: {} }), } const adapter = new RunnerAdapterV0() Object.assign(adapter as any, { boxApiClient }) @@ -30,35 +28,33 @@ describe('RunnerAdapterV0 capability propagation', () => { return box } - it('uses the strict create contract for capability overrides', async () => { + it('forwards the capability policy on create', async () => { const { adapter, boxApiClient } = makeAdapter() await adapter.createBox(customCapabilityBox()) - expect(boxApiClient.createWithCapabilities).toHaveBeenCalledWith( + expect(boxApiClient.create).toHaveBeenCalledWith( expect.objectContaining({ advanced: { capabilities: { add: ['SYS_ADMIN'], drop: ['NET_RAW'] } }, }), ) - expect(boxApiClient.create).not.toHaveBeenCalled() }) - it('uses the strict recovery contract for capability overrides', async () => { + it('forwards the capability policy on recovery', async () => { const { adapter, boxApiClient } = makeAdapter() const box = customCapabilityBox() await adapter.recoverBox(box) - expect(boxApiClient.recoverWithCapabilities).toHaveBeenCalledWith( + expect(boxApiClient.recover).toHaveBeenCalledWith( box.id, expect.objectContaining({ advanced: { capabilities: { add: ['SYS_ADMIN'], drop: ['NET_RAW'] } }, }), ) - expect(boxApiClient.recover).not.toHaveBeenCalled() }) - it('keeps capability-free creates on the legacy contract', async () => { + it('sends an empty policy for a box without capability overrides', async () => { const { adapter, boxApiClient } = makeAdapter() const box = customCapabilityBox() box.advanced = { capabilities: { add: [], drop: [] } } @@ -66,22 +62,7 @@ describe('RunnerAdapterV0 capability propagation', () => { await adapter.createBox(box) expect(boxApiClient.create).toHaveBeenCalledWith( - expect.not.objectContaining({ advanced: expect.anything() }), - ) - expect(boxApiClient.createWithCapabilities).not.toHaveBeenCalled() - }) - - it('keeps capability-free recovery on the legacy contract', async () => { - const { adapter, boxApiClient } = makeAdapter() - const box = customCapabilityBox() - box.advanced = { capabilities: { add: [], drop: [] } } - - await adapter.recoverBox(box) - - expect(boxApiClient.recover).toHaveBeenCalledWith( - box.id, - expect.not.objectContaining({ advanced: expect.anything() }), + expect.objectContaining({ advanced: { capabilities: { add: [], drop: [] } } }), ) - expect(boxApiClient.recoverWithCapabilities).not.toHaveBeenCalled() }) }) diff --git a/apps/api/src/box/runner-adapter/runnerAdapter.v0.ts b/apps/api/src/box/runner-adapter/runnerAdapter.v0.ts index e4da3dcce..5552cface 100644 --- a/apps/api/src/box/runner-adapter/runnerAdapter.v0.ts +++ b/apps/api/src/box/runner-adapter/runnerAdapter.v0.ts @@ -19,7 +19,7 @@ import { UpdateNetworkSettingsDTO, RecoverBoxDTO, } from '@boxlite-ai/runner-api-client' -import { hasCapabilityPolicy, normalizeBoxAdvancedOptions } from '../common/box-advanced-options' +import { normalizeBoxAdvancedOptions } from '../common/box-advanced-options' import { Box } from '../entities/box.entity' import { BoxState } from '../enums/box-state.enum' import { RunnerApiError } from '../errors/runner-api-error' @@ -267,14 +267,9 @@ export class RunnerAdapterV0 implements RunnerAdapter { authToken: box.authToken, organizationId: box.organizationId, regionId: box.region, + advanced: normalizeBoxAdvancedOptions(box.advanced), } - const advanced = normalizeBoxAdvancedOptions(box.advanced) - const response = hasCapabilityPolicy(advanced) - ? await this.boxApiClient.createWithCapabilities({ - ...createBoxDTO, - advanced, - }) - : await this.boxApiClient.create(createBoxDTO) + const response = await this.boxApiClient.create(createBoxDTO) if (!response?.data?.daemonVersion) { return undefined @@ -340,14 +335,7 @@ export class RunnerAdapterV0 implements RunnerAdapter { networkBlockAll: box.networkBlockAll, networkAllowList: box.networkAllowList, errorReason: box.errorReason, - } - const advanced = normalizeBoxAdvancedOptions(box.advanced) - if (hasCapabilityPolicy(advanced)) { - await this.boxApiClient.recoverWithCapabilities(box.id, { - ...recoverBoxDTO, - advanced, - }) - return + advanced: normalizeBoxAdvancedOptions(box.advanced), } await this.boxApiClient.recover(box.id, recoverBoxDTO) } diff --git a/apps/api/src/box/runner-adapter/runnerAdapter.v2.spec.ts b/apps/api/src/box/runner-adapter/runnerAdapter.v2.spec.ts index da2bcf26a..4a9e73420 100644 --- a/apps/api/src/box/runner-adapter/runnerAdapter.v2.spec.ts +++ b/apps/api/src/box/runner-adapter/runnerAdapter.v2.spec.ts @@ -27,7 +27,7 @@ describe('RunnerAdapterV2 capability propagation', () => { return box } - it('uses a distinct create job type for capability overrides', async () => { + it('forwards the capability policy on the create job', async () => { const { adapter, jobService } = makeAdapter() const box = customCapabilityBox() @@ -35,7 +35,7 @@ describe('RunnerAdapterV2 capability propagation', () => { expect(jobService.createJob).toHaveBeenCalledWith( null, - JobType.CREATE_BOX_WITH_CAPABILITIES_V2, + JobType.CREATE_BOX, 'runner-1', ResourceType.BOX, box.id, @@ -45,7 +45,7 @@ describe('RunnerAdapterV2 capability propagation', () => { ) }) - it('uses a distinct recovery job type for capability overrides', async () => { + it('forwards the capability policy on the recovery job', async () => { const { adapter, jobService } = makeAdapter() const box = customCapabilityBox() @@ -53,7 +53,7 @@ describe('RunnerAdapterV2 capability propagation', () => { expect(jobService.createJob).toHaveBeenCalledWith( null, - JobType.RECOVER_BOX_WITH_CAPABILITIES_V2, + JobType.RECOVER_BOX, 'runner-1', ResourceType.BOX, box.id, @@ -63,7 +63,7 @@ describe('RunnerAdapterV2 capability propagation', () => { ) }) - it('keeps capability-free creates on the legacy job type', async () => { + it('sends an empty policy for a box without capability overrides', async () => { const { adapter, jobService } = makeAdapter() const box = customCapabilityBox() box.advanced = { capabilities: { add: [], drop: [] } } @@ -76,24 +76,7 @@ describe('RunnerAdapterV2 capability propagation', () => { 'runner-1', ResourceType.BOX, box.id, - expect.not.objectContaining({ advanced: expect.anything() }), - ) - }) - - it('keeps capability-free recovery on the legacy job type', async () => { - const { adapter, jobService } = makeAdapter() - const box = customCapabilityBox() - box.advanced = { capabilities: { add: [], drop: [] } } - - await adapter.recoverBox(box) - - expect(jobService.createJob).toHaveBeenCalledWith( - null, - JobType.RECOVER_BOX, - 'runner-1', - ResourceType.BOX, - box.id, - expect.not.objectContaining({ advanced: expect.anything() }), + expect.objectContaining({ advanced: { capabilities: { add: [], drop: [] } } }), ) }) }) diff --git a/apps/api/src/box/runner-adapter/runnerAdapter.v2.ts b/apps/api/src/box/runner-adapter/runnerAdapter.v2.ts index a178a773a..4dfb25b3d 100644 --- a/apps/api/src/box/runner-adapter/runnerAdapter.v2.ts +++ b/apps/api/src/box/runner-adapter/runnerAdapter.v2.ts @@ -13,7 +13,7 @@ import { Box } from '../entities/box.entity' import { Job } from '../entities/job.entity' import { BoxState } from '../enums/box-state.enum' import { JobType } from '../enums/job-type.enum' -import { hasCapabilityPolicy, normalizeBoxAdvancedOptions } from '../common/box-advanced-options' +import { normalizeBoxAdvancedOptions } from '../common/box-advanced-options' import { JobStatus } from '../enums/job-status.enum' import { ResourceType } from '../enums/resource-type.enum' import { JobService } from '../services/job.service' @@ -102,7 +102,6 @@ export class RunnerAdapterV2 implements RunnerAdapter { // Map job types to transitional states switch (job.type) { case JobType.CREATE_BOX: - case JobType.CREATE_BOX_WITH_CAPABILITIES_V2: return job.status === JobStatus.COMPLETED ? BoxState.STARTED : BoxState.CREATING case JobType.START_BOX: return job.status === JobStatus.COMPLETED ? BoxState.STARTED : BoxState.STARTING @@ -143,14 +142,12 @@ export class RunnerAdapterV2 implements RunnerAdapter { regionId: box.region, } - const advanced = normalizeBoxAdvancedOptions(box.advanced) - const hasCustomCapabilities = hasCapabilityPolicy(advanced) - const jobType = hasCustomCapabilities ? JobType.CREATE_BOX_WITH_CAPABILITIES_V2 : JobType.CREATE_BOX - const jobPayload = hasCustomCapabilities ? { ...payload, advanced } : payload - - await this.jobService.createJob(null, jobType, this.runner.id, ResourceType.BOX, box.id, jobPayload) + await this.jobService.createJob(null, JobType.CREATE_BOX, this.runner.id, ResourceType.BOX, box.id, { + ...payload, + advanced: normalizeBoxAdvancedOptions(box.advanced), + }) - this.logger.debug(`Created ${jobType} job for box ${box.id} on runner ${this.runner.id}`) + this.logger.debug(`Created CREATE_BOX job for box ${box.id} on runner ${this.runner.id}`) // Daemon version is set in the job result metadata once the runner completes the job. return undefined @@ -203,14 +200,12 @@ export class RunnerAdapterV2 implements RunnerAdapter { networkAllowList: box.networkAllowList, errorReason: box.errorReason, } - const advanced = normalizeBoxAdvancedOptions(box.advanced) - const hasCustomCapabilities = hasCapabilityPolicy(advanced) - const jobType = hasCustomCapabilities ? JobType.RECOVER_BOX_WITH_CAPABILITIES_V2 : JobType.RECOVER_BOX - const jobPayload = hasCustomCapabilities ? { ...recoverBoxDTO, advanced } : recoverBoxDTO - - await this.jobService.createJob(null, jobType, this.runner.id, ResourceType.BOX, box.id, jobPayload) + await this.jobService.createJob(null, JobType.RECOVER_BOX, this.runner.id, ResourceType.BOX, box.id, { + ...recoverBoxDTO, + advanced: normalizeBoxAdvancedOptions(box.advanced), + }) - this.logger.debug(`Created ${jobType} job for box ${box.id} on runner ${this.runner.id}`) + this.logger.debug(`Created RECOVER_BOX job for box ${box.id} on runner ${this.runner.id}`) } async updateNetworkSettings( diff --git a/apps/api/src/box/services/box.service.spec.ts b/apps/api/src/box/services/box.service.spec.ts index 87cdeb31b..beac32535 100644 --- a/apps/api/src/box/services/box.service.spec.ts +++ b/apps/api/src/box/services/box.service.spec.ts @@ -3,7 +3,7 @@ * SPDX-License-Identifier: AGPL-3.0 */ -import { ConflictException, ForbiddenException } from '@nestjs/common' +import { ForbiddenException } from '@nestjs/common' import { BoxService } from './box.service' import { BoxState } from '../enums/box-state.enum' import { BoxDesiredState } from '../enums/box-desired-state.enum' @@ -361,133 +361,3 @@ describe('BoxService public defaults', () => { ) }) }) - -describe('BoxService getOrCreate option compatibility', () => { - function createGetOrCreateService(actualCapabilities: { add: string[]; drop: string[] }) { - const service = Object.create(BoxService.prototype) as BoxService - const existing = { - id: 'box-1', - name: 'named', - organizationId: 'org-1', - state: BoxState.STARTED, - advanced: { capabilities: actualCapabilities }, - } - ;(service as any).boxRepository = { - findOne: jest.fn().mockResolvedValue(existing), - } - service.toBoxDto = jest.fn().mockResolvedValue(existing as any) - service.create = jest.fn() - return service - } - - function createDuplicateRaceService(winner: any | null) { - const service = Object.create(BoxService.prototype) as BoxService - const conflict = new ConflictException('Box with name named already exists') - ;(service as any).boxRepository = { - findOne: jest.fn().mockResolvedValueOnce(null).mockResolvedValueOnce(winner), - } - service.toBoxDto = jest.fn().mockResolvedValue(winner) - service.create = jest.fn().mockRejectedValue(conflict) - return { service, conflict } - } - - it('rejects an incompatible existing capability policy', async () => { - const service = createGetOrCreateService({ add: ['SYS_ADMIN'], drop: [] }) - - await expect( - service.getOrCreate( - { - name: 'named', - advanced: { capabilities: { add: ['NET_ADMIN'], drop: [] } }, - }, - { id: 'org-1' } as any, - ), - ).rejects.toThrow('does not match') - expect(service.create).not.toHaveBeenCalled() - }) - - it('accepts equivalent normalized capability policies', async () => { - const service = createGetOrCreateService({ - add: ['NET_ADMIN', 'CAP_SYS_ADMIN'], - drop: [], - }) - - const result = await service.getOrCreate( - { - name: 'named', - advanced: { - capabilities: { add: ['sys_admin', 'CAP_NET_ADMIN'], drop: [] }, - }, - }, - { id: 'org-1' } as any, - ) - - expect(result.created).toBe(false) - expect(result.box).toMatchObject({ id: 'box-1' }) - expect(service.create).not.toHaveBeenCalled() - }) - - it('validates lifecycle options before returning an existing box', async () => { - const service = createGetOrCreateService({ add: [], drop: [] }) - - await expect( - service.getOrCreate( - { - name: 'named', - autoPause: 10, - autoDelete: 5, - }, - { id: 'org-1' } as any, - ), - ).rejects.toThrow('greater than auto-pause') - }) - - it('adopts a compatible winner after losing a duplicate-create race', async () => { - const winner = { - id: 'box-1', - name: 'named', - organizationId: 'org-1', - state: BoxState.STARTED, - advanced: { capabilities: { add: ['CAP_SYS_ADMIN'], drop: [] } }, - } - const { service } = createDuplicateRaceService(winner) - - const result = await service.getOrCreate( - { - name: 'named', - advanced: { capabilities: { add: ['sys_admin'], drop: [] } }, - }, - { id: 'org-1' } as any, - ) - - expect(result).toEqual({ box: winner, created: false }) - expect(service.create).toHaveBeenCalledTimes(1) - }) - - it('rejects an incompatible winner after losing a duplicate-create race', async () => { - const winner = { - id: 'box-1', - name: 'named', - organizationId: 'org-1', - state: BoxState.STARTED, - advanced: { capabilities: { add: ['SYS_ADMIN'], drop: [] } }, - } - const { service } = createDuplicateRaceService(winner) - - await expect( - service.getOrCreate( - { - name: 'named', - advanced: { capabilities: { add: ['NET_ADMIN'], drop: [] } }, - }, - { id: 'org-1' } as any, - ), - ).rejects.toThrow('does not match') - }) - - it('propagates the create conflict when no duplicate-race winner exists', async () => { - const { service, conflict } = createDuplicateRaceService(null) - - await expect(service.getOrCreate({ name: 'named' }, { id: 'org-1' } as any)).rejects.toBe(conflict) - }) -}) diff --git a/apps/api/src/box/services/box.service.ts b/apps/api/src/box/services/box.service.ts index 622c719bd..e850790cd 100644 --- a/apps/api/src/box/services/box.service.ts +++ b/apps/api/src/box/services/box.service.ts @@ -92,17 +92,6 @@ const DEFAULT_BOX_DISK = 10 const DEFAULT_BOX_GPU = 0 const TERMINAL_PREVIEW_PORT = 22222 -function canonicalCapabilities(capabilities: readonly string[]): string[] { - return [ - ...new Set( - capabilities.map((capability) => { - const normalized = capability.toUpperCase() - return normalized === 'ALL' ? normalized : normalized.replace(/^CAP_/, '') - }), - ), - ].sort() -} - @Injectable() export class BoxService { private readonly logger = new Logger(BoxService.name) @@ -196,7 +185,8 @@ export class BoxService { await this.volumeService.validateVolumes(organization.id, volumeIdOrNames) } else if (image && !hasCustomCapabilities) { // No volumes requested — try to claim a pre-warmed box matching this image/spec - // before creating a fresh one. + // before creating a fresh one. Warm-pool boxes were created with the + // default capability set, so a custom policy has to build a fresh box. const skipWarmPool = (await this.redis.exists(`warm-pool:skip:${image}`)) === 1 if (!skipWarmPool) { const warmPoolBox = await this.warmPoolService.fetchWarmPoolBox({ @@ -222,13 +212,8 @@ export class BoxService { const runner = await this.runnerService.getRandomAvailableRunner({ regions: [region.id], boxClass, - requiredFeatures: hasCustomCapabilities ? requiredRunnerFeatures : undefined, + requiredFeatures: requiredRunnerFeatures, }) - if (!runnerSupportsFeatures(runner.features, requiredRunnerFeatures)) { - throw new BadRequestError( - `Runner ${runner.id} does not support required feature: ${requiredRunnerFeatures.join(', ')}`, - ) - } const box = new Box(region.id, createBoxDto.name) @@ -302,74 +287,6 @@ export class BoxService { } } - async getOrCreate( - createBoxDto: CreateBoxDto, - organization: Organization, - ): Promise<{ box: BoxDto; created: boolean }> { - this.resolveLifecyclePolicy({ - autoPause: createBoxDto.autoPause, - autoDelete: createBoxDto.autoDelete, - autoResume: createBoxDto.autoResume, - }) - - if (!createBoxDto.name) { - return { box: await this.create(createBoxDto, organization), created: true } - } - - const existing = await this.findReusableBoxByName(createBoxDto.name, organization.id) - if (existing) { - this.checkOptionsCompatibility(createBoxDto, existing) - return { box: await this.toBoxDto(existing), created: false } - } - - try { - return { box: await this.create(createBoxDto, organization), created: true } - } catch (error) { - if (!(error instanceof ConflictException)) { - throw error - } - - const winner = await this.findReusableBoxByName(createBoxDto.name, organization.id) - if (!winner) { - throw error - } - this.checkOptionsCompatibility(createBoxDto, winner) - return { box: await this.toBoxDto(winner), created: false } - } - } - - private async findReusableBoxByName(name: string, organizationId: string): Promise { - const box = await this.boxRepository.findOne({ - where: { - name, - organizationId, - state: Not(BoxState.DESTROYED), - }, - }) - if (box?.state === BoxState.ERROR && box.desiredState === BoxDesiredState.DESTROYED) { - return null - } - return box - } - - private checkOptionsCompatibility(requested: CreateBoxDto, actual: Box): void { - const requestedCapabilities = normalizeBoxAdvancedOptions(requested.advanced).capabilities - const actualCapabilities = normalizeBoxAdvancedOptions(actual.advanced).capabilities - const hasSameCapabilities = (['add', 'drop'] as const).every((field) => { - const requestedSet = canonicalCapabilities(requestedCapabilities[field]) - const actualSet = canonicalCapabilities(actualCapabilities[field]) - return ( - requestedSet.length === actualSet.length && requestedSet.every((value, index) => value === actualSet[index]) - ) - }) - - if (!hasSameCapabilities) { - throw new BadRequestError( - `requested capability policy does not match the authoritative policy for box '${actual.name || actual.id}'`, - ) - } - } - private async assignWarmPoolBox( warmPoolBox: Box, createBoxDto: CreateBoxDto, @@ -1020,7 +937,7 @@ export class BoxService { if (!box.runnerId) { throw new NotFoundException(`Box with ID ${box.id} does not have a runner`) } - const runner = await this.runnerService.findOneCurrentOrFail(box.runnerId) + const runner = await this.runnerService.findOneOrFail(box.runnerId) const requiredRunnerFeatures = requiredRunnerFeaturesForCapabilities(box.advanced.capabilities) if (!runnerSupportsFeatures(runner.features, requiredRunnerFeatures)) { diff --git a/apps/api/src/box/services/job-state-handler.service.ts b/apps/api/src/box/services/job-state-handler.service.ts index 37e544802..8ae333836 100644 --- a/apps/api/src/box/services/job-state-handler.service.ts +++ b/apps/api/src/box/services/job-state-handler.service.ts @@ -45,7 +45,6 @@ export class JobStateHandlerService { switch (job.type) { case JobType.CREATE_BOX: - case JobType.CREATE_BOX_WITH_CAPABILITIES_V2: await this.handleCreateBoxJobCompletion(job) break case JobType.START_BOX: @@ -63,7 +62,6 @@ export class JobStateHandlerService { // TODO(image-rewrite): PULL_IMAGE / REMOVE_IMAGE job handling removed with // the runner image subsystems; rebuild artifact lifecycle handling here. case JobType.RECOVER_BOX: - case JobType.RECOVER_BOX_WITH_CAPABILITIES_V2: await this.handleRecoverBoxJobCompletion(job) break default: diff --git a/apps/api/src/box/services/runner.service.ts b/apps/api/src/box/services/runner.service.ts index 2ee9393ce..59ed908ce 100644 --- a/apps/api/src/box/services/runner.service.ts +++ b/apps/api/src/box/services/runner.service.ts @@ -225,15 +225,6 @@ export class RunnerService { return runner } - /** Read security-sensitive runner state directly from the database. */ - async findOneCurrentOrFail(id: string): Promise { - const runner = await this.runnerRepository.findOne({ where: { id } }) - if (!runner) { - throw new NotFoundException(`Runner with ID ${id} not found`) - } - return runner - } - async findOneFullOrFail(id: string): Promise { const runner = await this.findOneOrFail(id) const region = await this.regionService.findOne(runner.region) diff --git a/apps/api/src/box/utils/capability-validation.util.ts b/apps/api/src/box/utils/capability-validation.util.ts index 2b6245074..f88b8326b 100644 --- a/apps/api/src/box/utils/capability-validation.util.ts +++ b/apps/api/src/box/utils/capability-validation.util.ts @@ -3,7 +3,7 @@ * SPDX-License-Identifier: AGPL-3.0 */ -import { ValidatorConstraint, ValidatorConstraintInterface } from 'class-validator' +import { ValidationArguments, ValidatorConstraint, ValidatorConstraintInterface } from 'class-validator' export function isValidLinuxCapabilityName(value: unknown): boolean { if ( @@ -33,3 +33,31 @@ export class IsLinuxCapabilityNameConstraint implements ValidatorConstraintInter return 'each capability must be a Linux capability name or ALL' } } + +/** + * Reject keys the capability policy does not define. + * + * The global validation pipe does not strip unknown properties, so a + * misspelled security field would otherwise be accepted and ignored — the box + * would start with the default capability set while the caller believes a + * policy was applied. + */ +@ValidatorConstraint({ name: 'hasNoUnknownCapabilityFields', async: false }) +export class HasNoUnknownCapabilityFieldsConstraint implements ValidatorConstraintInterface { + validate(value: unknown, args: ValidationArguments): boolean { + if (typeof value !== 'object' || value === null) { + return true + } + // class-transformer materializes declared-but-absent keys as undefined, so + // presence alone does not mean the caller sent them. + const known = args.constraints[0] as string[] + return Object.entries(value as Record) + .filter(([, entry]) => entry !== undefined) + .every(([key]) => known.includes(key)) + } + + defaultMessage(args: ValidationArguments): string { + const known = (args.constraints[0] as string[]).join(', ') + return `${args.property} accepts only: ${known}` + } +} diff --git a/apps/api/src/boxlite-rest/boxlite-box.controller.ts b/apps/api/src/boxlite-rest/boxlite-box.controller.ts index d208a7db3..5fa9fc88c 100644 --- a/apps/api/src/boxlite-rest/boxlite-box.controller.ts +++ b/apps/api/src/boxlite-rest/boxlite-box.controller.ts @@ -11,13 +11,10 @@ import { Delete, Head, Body, - BadRequestException, Param, Query, HttpCode, UseGuards, - UsePipes, - ValidationPipe, Logger, Res, } from '@nestjs/common' @@ -32,15 +29,12 @@ import { BoxStateWaiterService } from '../box/services/box-state-waiter.service' import { Box } from '../box/entities/box.entity' import { BoxState } from '../box/enums/box-state.enum' import { BoxDesiredState } from '../box/enums/box-desired-state.enum' -import { BoxResponseDto, GetOrCreateBoxResponseDto, ListBoxesResponseDto } from './dto/box-response.dto' +import { BoxResponseDto, ListBoxesResponseDto } from './dto/box-response.dto' import { CreateBoxDto } from './dto/create-box.dto' import { boxToBoxResponse, createBoxToCreateBox } from './mappers/box-to-box.mapper' import { Audit, MASKED_AUDIT_VALUE, TypedRequest } from '../audit/decorators/audit.decorator' import { AuditAction } from '../audit/enums/audit-action.enum' import { AuditTarget } from '../audit/enums/audit-target.enum' - -const LEGACY_CAPABILITY_FIELDS = ['capAdd', 'capDrop', 'cap_add', 'cap_drop'] as const - // Spec-first surface: the contract is openapi/box.openapi.yaml, not the // generated product spec (which `:prefix` routes would render invalid). @ApiExcludeController() @@ -93,17 +87,6 @@ export class BoxliteBoxController { @AuthContext() authContext: OrganizationAuthContext, @Body() dto: CreateBoxDto, ): Promise { - const request = dto as CreateBoxDto & Record - const hasFlatCapabilityField = LEGACY_CAPABILITY_FIELDS.some((field) => - Object.prototype.hasOwnProperty.call(request, field), - ) - if (dto.advanced !== undefined || hasFlatCapabilityField) { - throw new BadRequestException('advanced options require POST /v1/boxes/strict') - } - return this.createBoxWithOptions(authContext, dto) - } - - private async createBoxWithOptions(authContext: OrganizationAuthContext, dto: CreateBoxDto): Promise { const organization = authContext.organization const createBoxDto = createBoxToCreateBox(dto) @@ -114,102 +97,6 @@ export class BoxliteBoxController { return boxToBoxResponse(box) } - /** - * Fail-closed create route for options that older API builds may not know. - * Capability-aware clients use this path so a mixed-version deployment - * returns 404 on an old instance instead of silently stripping cap fields. - */ - @Post('strict') - @HttpCode(201) - @UsePipes(new ValidationPipe({ transform: true, whitelist: true, forbidNonWhitelisted: true })) - @ApiResponse({ - status: 201, - description: 'Box created with strict option handling', - type: BoxResponseDto, - }) - @Audit({ - action: AuditAction.CREATE, - targetType: AuditTarget.BOX, - targetIdFromResult: (result: BoxResponseDto) => result?.box_id, - requestMetadata: { - body: (req: TypedRequest) => ({ - name: req.body?.name, - image: req.body?.image, - user: req.body?.user, - env: req.body?.env - ? Object.fromEntries(Object.keys(req.body?.env).map((key) => [key, MASKED_AUDIT_VALUE])) - : undefined, - cpus: req.body?.cpus, - memory_mib: req.body?.memory_mib, - disk_size_gb: req.body?.disk_size_gb, - working_dir: req.body?.working_dir, - entrypoint: req.body?.entrypoint, - cmd: req.body?.cmd, - advanced: req.body?.advanced, - detach: req.body?.detach, - auto_pause: req.body?.auto_pause, - auto_delete: req.body?.auto_delete, - auto_resume: req.body?.auto_resume, - }), - }, - }) - async createBoxStrict( - @AuthContext() authContext: OrganizationAuthContext, - @Body() dto: CreateBoxDto, - ): Promise { - return this.createBoxWithOptions(authContext, dto) - } - - @Post('get-or-create/strict') - @HttpCode(200) - @UsePipes(new ValidationPipe({ transform: true, whitelist: true, forbidNonWhitelisted: true })) - @ApiResponse({ - status: 200, - description: 'Existing or newly created box', - type: GetOrCreateBoxResponseDto, - }) - @Audit({ - action: AuditAction.CREATE, - targetType: AuditTarget.BOX, - targetIdFromResult: (result: GetOrCreateBoxResponseDto) => result?.box_info?.box_id, - requestMetadata: { - body: (req: TypedRequest) => ({ - name: req.body?.name, - image: req.body?.image, - user: req.body?.user, - env: req.body?.env - ? Object.fromEntries(Object.keys(req.body?.env).map((key) => [key, MASKED_AUDIT_VALUE])) - : undefined, - cpus: req.body?.cpus, - memory_mib: req.body?.memory_mib, - disk_size_gb: req.body?.disk_size_gb, - working_dir: req.body?.working_dir, - entrypoint: req.body?.entrypoint, - cmd: req.body?.cmd, - advanced: req.body?.advanced, - detach: req.body?.detach, - auto_pause: req.body?.auto_pause, - auto_delete: req.body?.auto_delete, - auto_resume: req.body?.auto_resume, - }), - }, - }) - async getOrCreateBoxStrict( - @AuthContext() authContext: OrganizationAuthContext, - @Body() dto: CreateBoxDto, - ): Promise { - const createBoxDto = createBoxToCreateBox(dto) - const result = await this.boxService.getOrCreate(createBoxDto, authContext.organization) - let box = result.box - if (result.created && box.state !== BoxState.STARTED) { - box = await this.boxStateWaiter.waitForStarted(box.id, authContext.organizationId, 30) - } - return { - box_info: boxToBoxResponse(box), - created: result.created, - } - } - @Get() @ApiResponse({ status: 200, diff --git a/apps/api/src/boxlite-rest/boxlite-rest-routing.spec.ts b/apps/api/src/boxlite-rest/boxlite-rest-routing.spec.ts index 544f2cd57..ad03ef283 100644 --- a/apps/api/src/boxlite-rest/boxlite-rest-routing.spec.ts +++ b/apps/api/src/boxlite-rest/boxlite-rest-routing.spec.ts @@ -39,16 +39,6 @@ describe('BoxLite REST routing', () => { findAllDeprecated: jest.fn().mockResolvedValue([]), toBoxDtos: jest.fn().mockResolvedValue([]), findOneByIdOrName: jest.fn().mockResolvedValue({ id: 'box-1' }), - getOrCreate: jest.fn().mockResolvedValue({ - box: { - id: 'box-1', - name: 'named', - state: BoxState.STARTED, - labels: {}, - advanced: { capabilities: { add: ['SYS_ADMIN'], drop: ['NET_RAW'] } }, - }, - created: false, - }), toBoxDto: jest.fn().mockResolvedValue({ id: 'box-1', name: 'named', @@ -87,15 +77,6 @@ describe('BoxLite REST routing', () => { return fetch(`http://127.0.0.1:${address.port}${path}`) } - async function post(path: string, body: unknown): Promise { - const address = app.getHttpServer().address() as AddressInfo - return fetch(`http://127.0.0.1:${address.port}${path}`, { - method: 'POST', - headers: { 'content-type': 'application/json' }, - body: JSON.stringify(body), - }) - } - afterEach(async () => { await app?.close() }) @@ -125,89 +106,19 @@ describe('BoxLite REST routing', () => { expect(await legacy.json()).toEqual({ boxes: [] }) }) - it('delegates strict get-or-create compatibility to the box service', async () => { - await startRoutingTestApp() - - const response = await post('/api/v1/boxes/get-or-create/strict', { - name: 'named', - image: 'alpine:latest', - advanced: { capabilities: { add: [], drop: [] } }, - }) - - expect(response.status).toBe(200) - const body = await response.json() - expect(body).toMatchObject({ - box_info: { - box_id: 'box-1', - }, - created: false, - }) - expect(body.box_info.advanced).toBeUndefined() - }) - - it('rejects unknown fields at the strict create boundary', async () => { + it('serves the box read route without exposing capability metadata', async () => { await startRoutingTestApp() - const response = await post('/api/v1/boxes/strict', { - image: 'alpine:latest', - advanced: { - capabilities: { - drop: ['NET_RAW'], - future_security_option: true, - }, - }, - }) - - expect(response.status).toBe(400) - }) - - it('rejects explicit null throughout the strict advanced capability path', async () => { - await startRoutingTestApp() + const canonical = await get('/api/v1/boxes/named') + const prefixed = await get('/api/v1/default/boxes/named') - const payloads = [ - { advanced: null }, - { advanced: { capabilities: null } }, - { advanced: { capabilities: { add: null } } }, - { advanced: { capabilities: { drop: null } } }, - ] - - for (const payload of payloads) { - const response = await post('/api/v1/boxes/strict', { - image: 'alpine:latest', - ...payload, - }) - expect(response.status).toBe(400) - } + expect(canonical.status).toBe(200) + const body = await canonical.json() + expect(body).toMatchObject({ box_id: 'box-1' }) + expect(body.advanced).toBeUndefined() + expect(prefixed.status).toBe(200) }) - it.each([null, {}, { capabilities: { add: [], drop: [] } }])( - 'rejects any advanced key at the legacy create boundary', - async (advanced) => { - await startRoutingTestApp() - - const response = await post('/api/v1/boxes', { - image: 'alpine:latest', - advanced, - }) - - expect(response.status).toBe(400) - }, - ) - - it.each(['capAdd', 'capDrop', 'cap_add', 'cap_drop'])( - 'rejects prototype flat capability field %s at the legacy create boundary', - async (field) => { - await startRoutingTestApp() - - const response = await post('/api/v1/boxes', { - image: 'alpine:latest', - [field]: [], - }) - - expect(response.status).toBe(400) - }, - ) - it('matches websocket attach upgrades with or without a routing prefix', () => { const service = new BoxliteWsProxyService( {} as any, diff --git a/apps/api/src/boxlite-rest/dto/box-response.dto.ts b/apps/api/src/boxlite-rest/dto/box-response.dto.ts index ce6423fd4..7a16cc158 100644 --- a/apps/api/src/boxlite-rest/dto/box-response.dto.ts +++ b/apps/api/src/boxlite-rest/dto/box-response.dto.ts @@ -89,15 +89,6 @@ export class BoxResponseDto { auto_resume: boolean } -@ApiSchema({ name: 'GetOrCreateBoxResponse' }) -export class GetOrCreateBoxResponseDto { - @ApiProperty({ type: BoxResponseDto }) - box_info: BoxResponseDto - - @ApiProperty({ description: 'Whether this request created a new box' }) - created: boolean -} - @ApiSchema({ name: 'ListBoxesResponse' }) export class ListBoxesResponseDto { @ApiProperty({ type: [BoxResponseDto] }) diff --git a/apps/api/src/boxlite-rest/dto/create-box.dto.spec.ts b/apps/api/src/boxlite-rest/dto/create-box.dto.spec.ts index 35a3ee18d..d3e104f24 100644 --- a/apps/api/src/boxlite-rest/dto/create-box.dto.spec.ts +++ b/apps/api/src/boxlite-rest/dto/create-box.dto.spec.ts @@ -160,6 +160,18 @@ describe('CreateBoxDto capability validation', () => { expect(errors).toHaveLength(0) }) + it.each([ + ['advanced', 'advanced', { advanced: { capabilites: { drop: ['NET_RAW'] } } }], + ['advanced.capabilities', 'capabilities', { advanced: { capabilities: { dorp: ['NET_RAW'] } } }], + ])('rejects an unknown key under %s instead of ignoring it', async (_label, property, payload) => { + const errors = await validate(plainToInstance(CreateBoxDto, payload)) + const flattened = [...errors, ...errors.flatMap((error) => error.children ?? [])] + + expect(flattened.find((error) => error.property === property)?.constraints).toHaveProperty( + 'hasNoUnknownCapabilityFields', + ) + }) + it('rejects malformed capability names', async () => { for (const capability of ['NET-ADMIN', '123', 'ß']) { const errors = await validate( @@ -172,27 +184,3 @@ describe('CreateBoxDto capability validation', () => { } }) }) - -describe('CreateBoxDto unsupported cloud options', () => { - it.each([ - ['rootfs_path', '/tmp/rootfs'], - ['tty', true], - [ - 'secrets', - [ - { - name: 'registry-token', - value: 'redacted-test-value', - hosts: ['registry.example.com'], - placeholder: '', - }, - ], - ], - ])('rejects %s instead of silently dropping it', async (field, value) => { - const errors = await validate(plainToInstance(CreateBoxDto, { [field]: value })) - - expect(errors.find((error) => error.property === field)?.constraints).toHaveProperty( - 'isUnsupportedCloudCreateOption', - ) - }) -}) diff --git a/apps/api/src/boxlite-rest/dto/create-box.dto.ts b/apps/api/src/boxlite-rest/dto/create-box.dto.ts index f27060db6..2fcf48daf 100644 --- a/apps/api/src/boxlite-rest/dto/create-box.dto.ts +++ b/apps/api/src/boxlite-rest/dto/create-box.dto.ts @@ -18,12 +18,12 @@ import { Validate, ValidateIf, ValidateNested, - ValidationArguments, ValidatorConstraint, ValidatorConstraintInterface, } from 'class-validator' import { isValidNetworkAllowEntry, MAX_NETWORK_ALLOW_LIST_ENTRIES } from '../../box/utils/network-validation.util' import { CreateBoxAdvancedOptionsDto } from '../../box/dto/create-box.dto' +import { HasNoUnknownCapabilityFieldsConstraint } from '../../box/utils/capability-validation.util' @ValidatorConstraint({ name: 'isNetworkAllowEntry', async: false }) class IsNetworkAllowEntryConstraint implements ValidatorConstraintInterface { @@ -36,17 +36,6 @@ class IsNetworkAllowEntryConstraint implements ValidatorConstraintInterface { } } -@ValidatorConstraint({ name: 'isUnsupportedCloudCreateOption', async: false }) -class IsUnsupportedCloudCreateOptionConstraint implements ValidatorConstraintInterface { - validate(value: unknown): boolean { - return value === undefined - } - - defaultMessage(args: ValidationArguments): string { - return `${args.property} is not supported by the cloud REST API` - } -} - export class NetworkSpecDto { @IsIn(['enabled', 'disabled']) mode: 'enabled' | 'disabled' @@ -68,13 +57,6 @@ export class CreateBoxDto { @IsString() image?: string - // The local runtime can consume an OCI layout from its own filesystem, but - // a cloud API path cannot safely interpret a client-local path. Declare the - // wire field so strict validation reports the real incompatibility instead - // of treating it as an unknown option or silently dropping it. - @Validate(IsUnsupportedCloudCreateOptionConstraint) - rootfs_path?: string - // A box with 0 vCPUs can never boot (libkrun set_vm_config(0, ...) → EINVAL), // so reject undersized resources at the request boundary instead of accepting // a box that fails to start. @@ -101,12 +83,6 @@ export class CreateBoxDto { @IsObject() env?: Record - // Secret substitution has no persisted control-plane/runner contract yet. - // Reject it at both validation and mapper boundaries until that contract - // exists; accepting and discarding a secret would be unsafe. - @Validate(IsUnsupportedCloudCreateOptionConstraint) - secrets?: unknown[] - @IsOptional() @IsArray() entrypoint?: string[] @@ -122,6 +98,7 @@ export class CreateBoxDto { @ValidateIf((_object, value) => value !== undefined) @IsObject() @ValidateNested() + @Validate(HasNoUnknownCapabilityFieldsConstraint, [['capabilities']]) @Type(() => CreateBoxAdvancedOptionsDto) advanced?: CreateBoxAdvancedOptionsDto @@ -129,12 +106,6 @@ export class CreateBoxDto { @IsBoolean() detach?: boolean - // The runner create DTO currently has no container-init TTY field. Keep the - // strict endpoint fail-closed rather than degrading an interactive request - // to pipes. - @Validate(IsUnsupportedCloudCreateOptionConstraint) - tty?: boolean - @IsOptional() @IsNumber() @Min(0) diff --git a/apps/api/src/boxlite-rest/mappers/box-to-box.mapper.spec.ts b/apps/api/src/boxlite-rest/mappers/box-to-box.mapper.spec.ts index 84b506fa4..e8b92f5b1 100644 --- a/apps/api/src/boxlite-rest/mappers/box-to-box.mapper.spec.ts +++ b/apps/api/src/boxlite-rest/mappers/box-to-box.mapper.spec.ts @@ -7,26 +7,6 @@ import { BoxState } from '../../box/enums/box-state.enum' import { boxToBoxResponse, createBoxToCreateBox } from './box-to-box.mapper' describe('BoxLite lifecycle policy mapper', () => { - it.each([ - ['rootfs_path', { rootfs_path: '/tmp/rootfs' }], - ['tty', { tty: true }], - [ - 'secrets', - { - secrets: [ - { - name: 'registry-token', - value: 'redacted-test-value', - hosts: ['registry.example.com'], - placeholder: '', - }, - ], - }, - ], - ])('refuses to silently drop unsupported %s', (_field, request) => { - expect(() => createBoxToCreateBox(request as any)).toThrow('not supported by the cloud REST API') - }) - it('maps capability overrides into the control-plane DTO', () => { const mapped = createBoxToCreateBox({ advanced: { diff --git a/apps/api/src/boxlite-rest/mappers/box-to-box.mapper.ts b/apps/api/src/boxlite-rest/mappers/box-to-box.mapper.ts index 4d6b4a366..8deaa2d64 100644 --- a/apps/api/src/boxlite-rest/mappers/box-to-box.mapper.ts +++ b/apps/api/src/boxlite-rest/mappers/box-to-box.mapper.ts @@ -4,7 +4,6 @@ * SPDX-License-Identifier: AGPL-3.0 */ -import { BadRequestException } from '@nestjs/common' import { BoxDto } from '../../box/dto/box.dto' import { BoxState } from '../../box/enums/box-state.enum' import { @@ -34,8 +33,6 @@ export function boxToBoxResponse(box: BoxDto): BoxResponseDto { } export function createBoxToCreateBox(dto: RestCreateBoxDto, target?: string): CreateBoxDto { - rejectUnsupportedCloudCreateOptions(dto) - const createDto = new CreateBoxDto() createDto.name = dto.name createDto.image = dto.image @@ -57,15 +54,6 @@ export function createBoxToCreateBox(dto: RestCreateBoxDto, target?: string): Cr return createDto } -function rejectUnsupportedCloudCreateOptions(dto: RestCreateBoxDto): void { - const unsupportedFields = (['rootfs_path', 'tty', 'secrets'] as const).filter((field) => dto[field] !== undefined) - if (unsupportedFields.length === 0) { - return - } - - throw new BadRequestException(`${unsupportedFields.join(', ')} is not supported by the cloud REST API`) -} - function mapState(state: string | BoxState | undefined): string { switch (state) { case BoxState.STARTED: diff --git a/apps/libs/api-client/src/docs/JobType.md b/apps/libs/api-client/src/docs/JobType.md index 6eeed67a4..4b30a68bb 100644 --- a/apps/libs/api-client/src/docs/JobType.md +++ b/apps/libs/api-client/src/docs/JobType.md @@ -6,8 +6,6 @@ The type of the job * `CREATE_BOX` (value: `'CREATE_BOX'`) -* `CREATE_BOX_WITH_CAPABILITIES_V2` (value: `'CREATE_BOX_WITH_CAPABILITIES_V2'`) - * `START_BOX` (value: `'START_BOX'`) * `STOP_BOX` (value: `'STOP_BOX'`) @@ -22,8 +20,6 @@ The type of the job * `RECOVER_BOX` (value: `'RECOVER_BOX'`) -* `RECOVER_BOX_WITH_CAPABILITIES_V2` (value: `'RECOVER_BOX_WITH_CAPABILITIES_V2'`) - * `INSPECT_ARTIFACT_IN_REGISTRY` (value: `'INSPECT_ARTIFACT_IN_REGISTRY'`) * `REMOVE_ARTIFACT` (value: `'REMOVE_ARTIFACT'`) diff --git a/apps/libs/api-client/src/docs/RunnerHealthcheck.md b/apps/libs/api-client/src/docs/RunnerHealthcheck.md index 7834094b5..1ed624f8b 100644 --- a/apps/libs/api-client/src/docs/RunnerHealthcheck.md +++ b/apps/libs/api-client/src/docs/RunnerHealthcheck.md @@ -11,6 +11,7 @@ Name | Type | Description | Notes **proxyUrl** | **string** | Runner proxy URL | [optional] [default to undefined] **apiUrl** | **string** | Runner API URL | [optional] [default to undefined] **appVersion** | **string** | Runner app version | [default to undefined] +**features** | **Array<string>** | Optional runner features used for rollout negotiation | [optional] [default to undefined] ## Example @@ -24,6 +25,7 @@ const instance: RunnerHealthcheck = { proxyUrl, apiUrl, appVersion, + features, }; ``` diff --git a/apps/libs/api-client/src/models/box.ts b/apps/libs/api-client/src/models/box.ts index 3df670b90..7fb4eba20 100644 --- a/apps/libs/api-client/src/models/box.ts +++ b/apps/libs/api-client/src/models/box.ts @@ -12,141 +12,144 @@ * Do not edit the class manually. */ + // May contain unused imports in some cases // @ts-ignore -import type { BoxDesiredState } from './box-desired-state' +import type { BoxDesiredState } from './box-desired-state'; // May contain unused imports in some cases // @ts-ignore -import type { BoxState } from './box-state' +import type { BoxState } from './box-state'; // May contain unused imports in some cases // @ts-ignore -import type { BoxVolume } from './box-volume' +import type { BoxVolume } from './box-volume'; export interface Box { - /** - * The public 12-character Box ID - */ - id: string - /** - * The organization ID of the box - */ - organizationId: string - /** - * The name of the box - */ - name: string - /** - * The user associated with the project - */ - user: string - /** - * Environment variables for the box - */ - env: { [key: string]: string } - /** - * Labels for the box - */ - labels: { [key: string]: string } - /** - * Whether the box http preview is public - */ - public: boolean - /** - * Whether to block all network access for the box - */ - networkBlockAll: boolean - /** - * Comma-separated list of allowed CIDR network addresses for the box - */ - networkAllowList?: string - /** - * The target environment for the box - */ - target: string - /** - * The image used for the box - */ - image?: string - /** - * The CPU quota for the box - */ - cpu: number - /** - * The GPU quota for the box - */ - gpu: number - /** - * The memory quota for the box - */ - memory: number - /** - * The disk quota for the box - */ - disk: number - /** - * The state of the box - */ - state?: BoxState - /** - * The desired state of the box - */ - desiredState?: BoxDesiredState - /** - * The error reason of the box - */ - errorReason?: string - /** - * Whether the box error is recoverable. - */ - recoverable?: boolean - /** - * Auto-pause interval in seconds (0 means disabled) - */ - autoPause?: number - /** - * Auto-delete interval in seconds (0 means disabled) - */ - autoDelete?: number - /** - * Whether the box should be automatically resumed on proxy access - */ - autoResume?: boolean - /** - * Array of volumes attached to the box - */ - volumes?: Array - /** - * The creation timestamp of the box - */ - createdAt?: string - /** - * The last update timestamp of the box - */ - updatedAt?: string - /** - * The class of the box - * @deprecated - */ - class?: BoxClassEnum - /** - * The version of the daemon running in the box - */ - daemonVersion?: string - /** - * The runner ID of the box - */ - runnerId?: string - /** - * The toolbox proxy URL for the box - */ - toolboxProxyUrl: string + /** + * The public 12-character Box ID + */ + 'id': string; + /** + * The organization ID of the box + */ + 'organizationId': string; + /** + * The name of the box + */ + 'name': string; + /** + * The user associated with the project + */ + 'user': string; + /** + * Environment variables for the box + */ + 'env': { [key: string]: string; }; + /** + * Labels for the box + */ + 'labels': { [key: string]: string; }; + /** + * Whether the box http preview is public + */ + 'public': boolean; + /** + * Whether to block all network access for the box + */ + 'networkBlockAll': boolean; + /** + * Comma-separated list of allowed CIDR network addresses for the box + */ + 'networkAllowList'?: string; + /** + * The target environment for the box + */ + 'target': string; + /** + * The image used for the box + */ + 'image'?: string; + /** + * The CPU quota for the box + */ + 'cpu': number; + /** + * The GPU quota for the box + */ + 'gpu': number; + /** + * The memory quota for the box + */ + 'memory': number; + /** + * The disk quota for the box + */ + 'disk': number; + /** + * The state of the box + */ + 'state'?: BoxState; + /** + * The desired state of the box + */ + 'desiredState'?: BoxDesiredState; + /** + * The error reason of the box + */ + 'errorReason'?: string; + /** + * Whether the box error is recoverable. + */ + 'recoverable'?: boolean; + /** + * Auto-pause interval in seconds (0 means disabled) + */ + 'autoPause'?: number; + /** + * Auto-delete interval in seconds (0 means disabled) + */ + 'autoDelete'?: number; + /** + * Whether the box should be automatically resumed on proxy access + */ + 'autoResume'?: boolean; + /** + * Array of volumes attached to the box + */ + 'volumes'?: Array; + /** + * The creation timestamp of the box + */ + 'createdAt'?: string; + /** + * The last update timestamp of the box + */ + 'updatedAt'?: string; + /** + * The class of the box + * @deprecated + */ + 'class'?: BoxClassEnum; + /** + * The version of the daemon running in the box + */ + 'daemonVersion'?: string; + /** + * The runner ID of the box + */ + 'runnerId'?: string; + /** + * The toolbox proxy URL for the box + */ + 'toolboxProxyUrl': string; } export const BoxClassEnum = { - SMALL: 'small', - MEDIUM: 'medium', - LARGE: 'large', - UNKNOWN_DEFAULT_OPEN_API: '11184809', -} as const + SMALL: 'small', + MEDIUM: 'medium', + LARGE: 'large', + UNKNOWN_DEFAULT_OPEN_API: '11184809', +} as const; + +export type BoxClassEnum = typeof BoxClassEnum[keyof typeof BoxClassEnum]; + -export type BoxClassEnum = (typeof BoxClassEnum)[keyof typeof BoxClassEnum] diff --git a/apps/libs/api-client/src/models/job-type.ts b/apps/libs/api-client/src/models/job-type.ts index 2f1d10422..3c31d1e6f 100644 --- a/apps/libs/api-client/src/models/job-type.ts +++ b/apps/libs/api-client/src/models/job-type.ts @@ -20,7 +20,6 @@ export const JobType = { CREATE_BOX: 'CREATE_BOX', - CREATE_BOX_WITH_CAPABILITIES_V2: 'CREATE_BOX_WITH_CAPABILITIES_V2', START_BOX: 'START_BOX', STOP_BOX: 'STOP_BOX', DESTROY_BOX: 'DESTROY_BOX', @@ -28,7 +27,6 @@ export const JobType = { CREATE_BACKUP: 'CREATE_BACKUP', PULL_ARTIFACT: 'PULL_ARTIFACT', RECOVER_BOX: 'RECOVER_BOX', - RECOVER_BOX_WITH_CAPABILITIES_V2: 'RECOVER_BOX_WITH_CAPABILITIES_V2', INSPECT_ARTIFACT_IN_REGISTRY: 'INSPECT_ARTIFACT_IN_REGISTRY', REMOVE_ARTIFACT: 'REMOVE_ARTIFACT', UPDATE_BOX_NETWORK_SETTINGS: 'UPDATE_BOX_NETWORK_SETTINGS', @@ -37,3 +35,5 @@ export const JobType = { export type JobType = typeof JobType[keyof typeof JobType]; + + diff --git a/apps/libs/api-client/src/models/runner-healthcheck.ts b/apps/libs/api-client/src/models/runner-healthcheck.ts index 2b8deb1c2..9142f0e40 100644 --- a/apps/libs/api-client/src/models/runner-healthcheck.ts +++ b/apps/libs/api-client/src/models/runner-healthcheck.ts @@ -12,40 +12,42 @@ * Do not edit the class manually. */ + // May contain unused imports in some cases // @ts-ignore -import type { RunnerHealthMetrics } from './runner-health-metrics' +import type { RunnerHealthMetrics } from './runner-health-metrics'; // May contain unused imports in some cases // @ts-ignore -import type { RunnerServiceHealth } from './runner-service-health' +import type { RunnerServiceHealth } from './runner-service-health'; export interface RunnerHealthcheck { - /** - * Optional runner features used for rollout negotiation - */ - features?: Array - /** - * Runner metrics - */ - metrics?: RunnerHealthMetrics - /** - * Health status of individual services on the runner - */ - serviceHealth?: Array - /** - * Runner domain - */ - domain?: string - /** - * Runner proxy URL - */ - proxyUrl?: string - /** - * Runner API URL - */ - apiUrl?: string - /** - * Runner app version - */ - appVersion: string + /** + * Runner metrics + */ + 'metrics'?: RunnerHealthMetrics; + /** + * Health status of individual services on the runner + */ + 'serviceHealth'?: Array; + /** + * Runner domain + */ + 'domain'?: string; + /** + * Runner proxy URL + */ + 'proxyUrl'?: string; + /** + * Runner API URL + */ + 'apiUrl'?: string; + /** + * Runner app version + */ + 'appVersion': string; + /** + * Optional runner features used for rollout negotiation + */ + 'features'?: Array; } + diff --git a/apps/libs/runner-api-client/src/.openapi-generator/FILES b/apps/libs/runner-api-client/src/.openapi-generator/FILES index 33f5db08a..078b8a938 100644 --- a/apps/libs/runner-api-client/src/.openapi-generator/FILES +++ b/apps/libs/runner-api-client/src/.openapi-generator/FILES @@ -16,7 +16,6 @@ docs/BoxliteApi.md docs/BuildSnapshotRequestDTO.md docs/CreateBackupDTO.md docs/CreateBoxDTO.md -docs/CreateBoxWithCapabilitiesDTO.md docs/ContainerCapabilitiesDTO.md docs/DefaultApi.md docs/DtoVolumeDTO.md @@ -28,7 +27,6 @@ docs/IsRecoverableDTO.md docs/IsRecoverableResponse.md docs/PullSnapshotRequestDTO.md docs/RecoverBoxDTO.md -docs/RecoverBoxWithCapabilitiesDTO.md docs/RegistryDTO.md docs/RunnerInfoResponseDTO.md docs/RunnerMetrics.md @@ -48,7 +46,6 @@ models/box-info-response.ts models/build-snapshot-request-dto.ts models/create-backup-dto.ts models/create-box-dto.ts -models/create-box-with-capabilities-dto.ts models/container-capabilities-dto.ts models/dto-volume-dto.ts models/enums-backup-state.ts @@ -60,7 +57,6 @@ models/is-recoverable-dto.ts models/is-recoverable-response.ts models/pull-snapshot-request-dto.ts models/recover-box-dto.ts -models/recover-box-with-capabilities-dto.ts models/registry-dto.ts models/runner-info-response-dto.ts models/runner-metrics.ts diff --git a/apps/libs/runner-api-client/src/api/box-api.ts b/apps/libs/runner-api-client/src/api/box-api.ts index ca1387675..3d1799b62 100644 --- a/apps/libs/runner-api-client/src/api/box-api.ts +++ b/apps/libs/runner-api-client/src/api/box-api.ts @@ -27,7 +27,6 @@ import type { BoxInfoResponse } from '../models'; import type { CreateBackupDTO } from '../models'; // @ts-ignore import type { CreateBoxDTO } from '../models'; -import type { CreateBoxWithCapabilitiesDTO } from '../models'; // @ts-ignore import type { ErrorResponse } from '../models'; // @ts-ignore @@ -36,7 +35,6 @@ import type { IsRecoverableDTO } from '../models'; import type { IsRecoverableResponse } from '../models'; // @ts-ignore import type { RecoverBoxDTO } from '../models'; -import type { RecoverBoxWithCapabilitiesDTO } from '../models'; // @ts-ignore import type { StartBoxResponse } from '../models'; // @ts-ignore @@ -128,44 +126,6 @@ export const BoxApiAxiosParamCreator = function (configuration?: Configuration) options: localVarRequestOptions, }; }, - /** - * Fail-closed create contract for capability-bearing requests - * @summary Create a box with a capability policy - * @param {CreateBoxWithCapabilitiesDTO} box Create box with capabilities - * @param {*} [options] Override http request option. - * @throws {RequiredError} - */ - createWithCapabilities: async (box: CreateBoxWithCapabilitiesDTO, options: RawAxiosRequestConfig = {}): Promise => { - // verify required parameter 'box' is not null or undefined - assertParamExists('createWithCapabilities', 'box', box) - const localVarPath = `/boxes/strict`; - // use dummy base URL string because the URL constructor only accepts absolute URLs. - const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL); - let baseOptions; - if (configuration) { - baseOptions = configuration.baseOptions; - } - - const localVarRequestOptions = { method: 'POST', ...baseOptions, ...options}; - const localVarHeaderParameter = {} as any; - const localVarQueryParameter = {} as any; - - // authentication Bearer required - await setApiKeyToObject(localVarHeaderParameter, "Authorization", configuration) - - localVarHeaderParameter['Content-Type'] = 'application/json'; - localVarHeaderParameter['Accept'] = 'application/json'; - - setSearchParams(localVarUrlObj, localVarQueryParameter); - let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {}; - localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers}; - localVarRequestOptions.data = serializeDataIfNeeded(box, localVarRequestOptions, configuration) - - return { - url: toPathString(localVarUrlObj), - options: localVarRequestOptions, - }; - }, /** * Destroy box * @summary Destroy box @@ -361,48 +321,6 @@ export const BoxApiAxiosParamCreator = function (configuration?: Configuration) options: localVarRequestOptions, }; }, - /** - * Fail-closed recovery contract for capability-bearing requests - * @summary Recover a box with a capability policy - * @param {string} boxId Box ID - * @param {RecoverBoxWithCapabilitiesDTO} recovery Recovery parameters with capabilities - * @param {*} [options] Override http request option. - * @throws {RequiredError} - */ - recoverWithCapabilities: async (boxId: string, recovery: RecoverBoxWithCapabilitiesDTO, options: RawAxiosRequestConfig = {}): Promise => { - // verify required parameter 'boxId' is not null or undefined - assertParamExists('recoverWithCapabilities', 'boxId', boxId) - // verify required parameter 'recovery' is not null or undefined - assertParamExists('recoverWithCapabilities', 'recovery', recovery) - const localVarPath = `/boxes/{boxId}/recover/strict` - .replace('{boxId}', encodeURIComponent(String(boxId))); - // use dummy base URL string because the URL constructor only accepts absolute URLs. - const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL); - let baseOptions; - if (configuration) { - baseOptions = configuration.baseOptions; - } - - const localVarRequestOptions = { method: 'POST', ...baseOptions, ...options}; - const localVarHeaderParameter = {} as any; - const localVarQueryParameter = {} as any; - - // authentication Bearer required - await setApiKeyToObject(localVarHeaderParameter, "Authorization", configuration) - - localVarHeaderParameter['Content-Type'] = 'application/json'; - localVarHeaderParameter['Accept'] = 'application/json'; - - setSearchParams(localVarUrlObj, localVarQueryParameter); - let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {}; - localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers}; - localVarRequestOptions.data = serializeDataIfNeeded(recovery, localVarRequestOptions, configuration) - - return { - url: toPathString(localVarUrlObj), - options: localVarRequestOptions, - }; - }, /** * Start box * @summary Start box @@ -566,19 +484,6 @@ export const BoxApiFp = function(configuration?: Configuration) { const localVarOperationServerBasePath = operationServerMap['BoxApi.createBackup']?.[localVarOperationServerIndex]?.url; return (axios, basePath) => createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration)(axios, localVarOperationServerBasePath || basePath); }, - /** - * Fail-closed create contract for capability-bearing requests - * @summary Create a box with a capability policy - * @param {CreateBoxWithCapabilitiesDTO} box Create box with capabilities - * @param {*} [options] Override http request option. - * @throws {RequiredError} - */ - async createWithCapabilities(box: CreateBoxWithCapabilitiesDTO, options?: RawAxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise> { - const localVarAxiosArgs = await localVarAxiosParamCreator.createWithCapabilities(box, options); - const localVarOperationServerIndex = configuration?.serverIndex ?? 0; - const localVarOperationServerBasePath = operationServerMap['BoxApi.createWithCapabilities']?.[localVarOperationServerIndex]?.url; - return (axios, basePath) => createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration)(axios, localVarOperationServerBasePath || basePath); - }, /** * Destroy box * @summary Destroy box @@ -646,20 +551,6 @@ export const BoxApiFp = function(configuration?: Configuration) { const localVarOperationServerBasePath = operationServerMap['BoxApi.recover']?.[localVarOperationServerIndex]?.url; return (axios, basePath) => createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration)(axios, localVarOperationServerBasePath || basePath); }, - /** - * Fail-closed recovery contract for capability-bearing requests - * @summary Recover a box with a capability policy - * @param {string} boxId Box ID - * @param {RecoverBoxWithCapabilitiesDTO} recovery Recovery parameters with capabilities - * @param {*} [options] Override http request option. - * @throws {RequiredError} - */ - async recoverWithCapabilities(boxId: string, recovery: RecoverBoxWithCapabilitiesDTO, options?: RawAxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise> { - const localVarAxiosArgs = await localVarAxiosParamCreator.recoverWithCapabilities(boxId, recovery, options); - const localVarOperationServerIndex = configuration?.serverIndex ?? 0; - const localVarOperationServerBasePath = operationServerMap['BoxApi.recoverWithCapabilities']?.[localVarOperationServerIndex]?.url; - return (axios, basePath) => createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration)(axios, localVarOperationServerBasePath || basePath); - }, /** * Start box * @summary Start box @@ -733,16 +624,6 @@ export const BoxApiFactory = function (configuration?: Configuration, basePath?: createBackup(boxId: string, box: CreateBackupDTO, options?: RawAxiosRequestConfig): AxiosPromise { return localVarFp.createBackup(boxId, box, options).then((request) => request(axios, basePath)); }, - /** - * Fail-closed create contract for capability-bearing requests - * @summary Create a box with a capability policy - * @param {CreateBoxWithCapabilitiesDTO} box Create box with capabilities - * @param {*} [options] Override http request option. - * @throws {RequiredError} - */ - createWithCapabilities(box: CreateBoxWithCapabilitiesDTO, options?: RawAxiosRequestConfig): AxiosPromise { - return localVarFp.createWithCapabilities(box, options).then((request) => request(axios, basePath)); - }, /** * Destroy box * @summary Destroy box @@ -795,17 +676,6 @@ export const BoxApiFactory = function (configuration?: Configuration, basePath?: recover(boxId: string, recovery: RecoverBoxDTO, options?: RawAxiosRequestConfig): AxiosPromise { return localVarFp.recover(boxId, recovery, options).then((request) => request(axios, basePath)); }, - /** - * Fail-closed recovery contract for capability-bearing requests - * @summary Recover a box with a capability policy - * @param {string} boxId Box ID - * @param {RecoverBoxWithCapabilitiesDTO} recovery Recovery parameters with capabilities - * @param {*} [options] Override http request option. - * @throws {RequiredError} - */ - recoverWithCapabilities(boxId: string, recovery: RecoverBoxWithCapabilitiesDTO, options?: RawAxiosRequestConfig): AxiosPromise { - return localVarFp.recoverWithCapabilities(boxId, recovery, options).then((request) => request(axios, basePath)); - }, /** * Start box * @summary Start box @@ -870,17 +740,6 @@ export class BoxApi extends BaseAPI { return BoxApiFp(this.configuration).createBackup(boxId, box, options).then((request) => request(this.axios, this.basePath)); } - /** - * Fail-closed create contract for capability-bearing requests - * @summary Create a box with a capability policy - * @param {CreateBoxWithCapabilitiesDTO} box Create box with capabilities - * @param {*} [options] Override http request option. - * @throws {RequiredError} - */ - public createWithCapabilities(box: CreateBoxWithCapabilitiesDTO, options?: RawAxiosRequestConfig) { - return BoxApiFp(this.configuration).createWithCapabilities(box, options).then((request) => request(this.axios, this.basePath)); - } - /** * Destroy box * @summary Destroy box @@ -938,18 +797,6 @@ export class BoxApi extends BaseAPI { return BoxApiFp(this.configuration).recover(boxId, recovery, options).then((request) => request(this.axios, this.basePath)); } - /** - * Fail-closed recovery contract for capability-bearing requests - * @summary Recover a box with a capability policy - * @param {string} boxId Box ID - * @param {RecoverBoxWithCapabilitiesDTO} recovery Recovery parameters with capabilities - * @param {*} [options] Override http request option. - * @throws {RequiredError} - */ - public recoverWithCapabilities(boxId: string, recovery: RecoverBoxWithCapabilitiesDTO, options?: RawAxiosRequestConfig) { - return BoxApiFp(this.configuration).recoverWithCapabilities(boxId, recovery, options).then((request) => request(this.axios, this.basePath)); - } - /** * Start box * @summary Start box diff --git a/apps/libs/runner-api-client/src/docs/BoxApi.md b/apps/libs/runner-api-client/src/docs/BoxApi.md index c812171ce..d5e70a100 100644 --- a/apps/libs/runner-api-client/src/docs/BoxApi.md +++ b/apps/libs/runner-api-client/src/docs/BoxApi.md @@ -6,13 +6,11 @@ All URIs are relative to *http://localhost* |------------- | ------------- | -------------| |[**create**](#create) | **POST** /boxes | Create a box| |[**createBackup**](#createbackup) | **POST** /boxes/{boxId}/backup | Create box backup| -|[**createWithCapabilities**](#createwithcapabilities) | **POST** /boxes/strict | Create a box with a capability policy| |[**destroy**](#destroy) | **POST** /boxes/{boxId}/destroy | Destroy box| |[**getNetworkSettings**](#getnetworksettings) | **GET** /boxes/{boxId}/network-settings | Get box network settings| |[**info**](#info) | **GET** /boxes/{boxId} | Get box info| |[**isRecoverable**](#isrecoverable) | **POST** /boxes/{boxId}/is-recoverable | Check if box error is recoverable| |[**recover**](#recover) | **POST** /boxes/{boxId}/recover | Recover box from error state| -|[**recoverWithCapabilities**](#recoverwithcapabilities) | **POST** /boxes/{boxId}/recover/strict | Recover a box with a capability policy| |[**start**](#start) | **POST** /boxes/{boxId}/start | Start box| |[**stop**](#stop) | **POST** /boxes/{boxId}/stop | Stop box| |[**updateNetworkSettings**](#updatenetworksettings) | **POST** /boxes/{boxId}/network-settings | Update box network settings| @@ -134,63 +132,6 @@ const { status, data } = await apiInstance.createBackup( [[Back to top]](#) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to Model list]](../README.md#documentation-for-models) [[Back to README]](../README.md) -# **createWithCapabilities** -> StartBoxResponse createWithCapabilities(box) - -Fail-closed create contract for capability-bearing requests - -### Example - -```typescript -import { - BoxApi, - Configuration, - CreateBoxWithCapabilitiesDTO -} from './api'; - -const configuration = new Configuration(); -const apiInstance = new BoxApi(configuration); - -let box: CreateBoxWithCapabilitiesDTO; //Create box with capabilities - -const { status, data } = await apiInstance.createWithCapabilities( - box -); -``` - -### Parameters - -|Name | Type | Description | Notes| -|------------- | ------------- | ------------- | -------------| -| **box** | **CreateBoxWithCapabilitiesDTO**| Create box with capabilities | | - - -### Return type - -**StartBoxResponse** - -### Authorization - -[Bearer](../README.md#Bearer) - -### HTTP request headers - - - **Content-Type**: Not defined - - **Accept**: application/json - - -### HTTP response details -| Status code | Description | Response headers | -|-------------|-------------|------------------| -|**201** | Created | - | -|**400** | Bad Request | - | -|**401** | Unauthorized | - | -|**404** | Not Found | - | -|**409** | Conflict | - | -|**500** | Internal Server Error | - | - -[[Back to top]](#) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to Model list]](../README.md#documentation-for-models) [[Back to README]](../README.md) - # **destroy** > string destroy() @@ -449,66 +390,6 @@ const { status, data } = await apiInstance.recover( | **boxId** | [**string**] | Box ID | defaults to undefined| -### Return type - -**string** - -### Authorization - -[Bearer](../README.md#Bearer) - -### HTTP request headers - - - **Content-Type**: application/json - - **Accept**: application/json - - -### HTTP response details -| Status code | Description | Response headers | -|-------------|-------------|------------------| -|**200** | Box recovered | - | -|**400** | Bad Request | - | -|**401** | Unauthorized | - | -|**404** | Not Found | - | -|**409** | Conflict | - | -|**500** | Internal Server Error | - | - -[[Back to top]](#) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to Model list]](../README.md#documentation-for-models) [[Back to README]](../README.md) - -# **recoverWithCapabilities** -> string recoverWithCapabilities(recovery) - -Fail-closed recovery contract for capability-bearing requests - -### Example - -```typescript -import { - BoxApi, - Configuration, - RecoverBoxWithCapabilitiesDTO -} from './api'; - -const configuration = new Configuration(); -const apiInstance = new BoxApi(configuration); - -let boxId: string; //Box ID (default to undefined) -let recovery: RecoverBoxWithCapabilitiesDTO; //Recovery parameters with capabilities - -const { status, data } = await apiInstance.recoverWithCapabilities( - boxId, - recovery -); -``` - -### Parameters - -|Name | Type | Description | Notes| -|------------- | ------------- | ------------- | -------------| -| **recovery** | **RecoverBoxWithCapabilitiesDTO**| Recovery parameters with capabilities | | -| **boxId** | [**string**] | Box ID | defaults to undefined| - - ### Return type **string** diff --git a/apps/libs/runner-api-client/src/docs/CreateBoxDTO.md b/apps/libs/runner-api-client/src/docs/CreateBoxDTO.md index 9c8ddb838..b1dff2ae8 100644 --- a/apps/libs/runner-api-client/src/docs/CreateBoxDTO.md +++ b/apps/libs/runner-api-client/src/docs/CreateBoxDTO.md @@ -5,6 +5,7 @@ Name | Type | Description | Notes ------------ | ------------- | ------------- | ------------- +**advanced** | [**AdvancedBoxOptionsDTO**](AdvancedBoxOptionsDTO.md) | | [optional] [default to undefined] **authToken** | **string** | | [optional] [default to undefined] **cpuQuota** | **number** | | [optional] [default to undefined] **entrypoint** | **Array<string>** | | [optional] [default to undefined] diff --git a/apps/libs/runner-api-client/src/docs/CreateBoxWithCapabilitiesDTO.md b/apps/libs/runner-api-client/src/docs/CreateBoxWithCapabilitiesDTO.md deleted file mode 100644 index f13208425..000000000 --- a/apps/libs/runner-api-client/src/docs/CreateBoxWithCapabilitiesDTO.md +++ /dev/null @@ -1,60 +0,0 @@ -# CreateBoxWithCapabilitiesDTO - - -## Properties - -Name | Type | Description | Notes ------------- | ------------- | ------------- | ------------- -**advanced** | [**AdvancedBoxOptionsDTO**](AdvancedBoxOptionsDTO.md) | Advanced box configuration | [default to undefined] -**authToken** | **string** | | [optional] [default to undefined] -**cpuQuota** | **number** | | [optional] [default to undefined] -**entrypoint** | **Array<string>** | | [optional] [default to undefined] -**env** | **{ [key: string]: string; }** | | [optional] [default to undefined] -**fromVolumeId** | **string** | | [optional] [default to undefined] -**gpuQuota** | **number** | | [optional] [default to undefined] -**id** | **string** | | [default to undefined] -**image** | **string** | | [default to undefined] -**memoryQuota** | **number** | | [optional] [default to undefined] -**metadata** | **{ [key: string]: string; }** | | [optional] [default to undefined] -**networkAllowList** | **string** | | [optional] [default to undefined] -**networkBlockAll** | **boolean** | | [optional] [default to undefined] -**organizationId** | **string** | Nullable for backward compatibility | [optional] [default to undefined] -**osUser** | **string** | | [default to undefined] -**otelEndpoint** | **string** | | [optional] [default to undefined] -**regionId** | **string** | | [optional] [default to undefined] -**registry** | [**RegistryDTO**](RegistryDTO.md) | | [optional] [default to undefined] -**skipStart** | **boolean** | | [optional] [default to undefined] -**storageQuota** | **number** | | [optional] [default to undefined] -**volumes** | [**Array<DtoVolumeDTO>**](DtoVolumeDTO.md) | | [optional] [default to undefined] - -## Example - -```typescript -import { CreateBoxWithCapabilitiesDTO } from './api'; - -const instance: CreateBoxWithCapabilitiesDTO = { - advanced, - authToken, - cpuQuota, - entrypoint, - env, - fromVolumeId, - gpuQuota, - id, - image, - memoryQuota, - metadata, - networkAllowList, - networkBlockAll, - organizationId, - osUser, - otelEndpoint, - regionId, - registry, - skipStart, - storageQuota, - volumes, -}; -``` - -[[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md) diff --git a/apps/libs/runner-api-client/src/docs/RecoverBoxDTO.md b/apps/libs/runner-api-client/src/docs/RecoverBoxDTO.md index 1d650bfeb..01d0aac51 100644 --- a/apps/libs/runner-api-client/src/docs/RecoverBoxDTO.md +++ b/apps/libs/runner-api-client/src/docs/RecoverBoxDTO.md @@ -5,6 +5,7 @@ Name | Type | Description | Notes ------------ | ------------- | ------------- | ------------- +**advanced** | [**AdvancedBoxOptionsDTO**](AdvancedBoxOptionsDTO.md) | | [optional] [default to undefined] **backupErrorReason** | **string** | | [optional] [default to undefined] **cpuQuota** | **number** | | [optional] [default to undefined] **env** | **{ [key: string]: string; }** | | [optional] [default to undefined] diff --git a/apps/libs/runner-api-client/src/docs/RecoverBoxWithCapabilitiesDTO.md b/apps/libs/runner-api-client/src/docs/RecoverBoxWithCapabilitiesDTO.md deleted file mode 100644 index b156d1a27..000000000 --- a/apps/libs/runner-api-client/src/docs/RecoverBoxWithCapabilitiesDTO.md +++ /dev/null @@ -1,42 +0,0 @@ -# RecoverBoxWithCapabilitiesDTO - - -## Properties - -Name | Type | Description | Notes ------------- | ------------- | ------------- | ------------- -**advanced** | [**AdvancedBoxOptionsDTO**](AdvancedBoxOptionsDTO.md) | Advanced box configuration | [default to undefined] -**cpuQuota** | **number** | | [optional] [default to undefined] -**env** | **{ [key: string]: string; }** | | [optional] [default to undefined] -**errorReason** | **string** | | [default to undefined] -**fromVolumeId** | **string** | | [optional] [default to undefined] -**gpuQuota** | **number** | | [optional] [default to undefined] -**memoryQuota** | **number** | | [optional] [default to undefined] -**networkAllowList** | **string** | | [optional] [default to undefined] -**networkBlockAll** | **boolean** | | [optional] [default to undefined] -**osUser** | **string** | | [default to undefined] -**storageQuota** | **number** | | [optional] [default to undefined] -**volumes** | [**Array<DtoVolumeDTO>**](DtoVolumeDTO.md) | | [optional] [default to undefined] - -## Example - -```typescript -import { RecoverBoxWithCapabilitiesDTO } from './api'; - -const instance: RecoverBoxWithCapabilitiesDTO = { - advanced, - cpuQuota, - env, - errorReason, - fromVolumeId, - gpuQuota, - memoryQuota, - networkAllowList, - networkBlockAll, - osUser, - storageQuota, - volumes, -}; -``` - -[[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md) diff --git a/apps/libs/runner-api-client/src/models/create-box-dto.ts b/apps/libs/runner-api-client/src/models/create-box-dto.ts index 2558b7e2c..72fb102ea 100644 --- a/apps/libs/runner-api-client/src/models/create-box-dto.ts +++ b/apps/libs/runner-api-client/src/models/create-box-dto.ts @@ -13,6 +13,9 @@ */ +// May contain unused imports in some cases +// @ts-ignore +import type { AdvancedBoxOptionsDTO } from './advanced-box-options-dto'; // May contain unused imports in some cases // @ts-ignore import type { DtoVolumeDTO } from './dto-volume-dto'; @@ -21,6 +24,7 @@ import type { DtoVolumeDTO } from './dto-volume-dto'; import type { RegistryDTO } from './registry-dto'; export interface CreateBoxDTO { + 'advanced'?: AdvancedBoxOptionsDTO; 'authToken'?: string; 'cpuQuota'?: number; 'entrypoint'?: Array; diff --git a/apps/libs/runner-api-client/src/models/create-box-with-capabilities-dto.ts b/apps/libs/runner-api-client/src/models/create-box-with-capabilities-dto.ts deleted file mode 100644 index d0d38b535..000000000 --- a/apps/libs/runner-api-client/src/models/create-box-with-capabilities-dto.ts +++ /dev/null @@ -1,51 +0,0 @@ -/* tslint:disable */ -/* eslint-disable */ -/** - * BoxLite Runner API - * BoxLite Runner API - * - * The version of the OpenAPI document: v0.0.0-dev - * - * - * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). - * https://openapi-generator.tech - * Do not edit the class manually. - */ - - -// May contain unused imports in some cases -// @ts-ignore -import type { AdvancedBoxOptionsDTO } from './advanced-box-options-dto'; -// May contain unused imports in some cases -// @ts-ignore -import type { DtoVolumeDTO } from './dto-volume-dto'; -// May contain unused imports in some cases -// @ts-ignore -import type { RegistryDTO } from './registry-dto'; - -export interface CreateBoxWithCapabilitiesDTO { - 'advanced': AdvancedBoxOptionsDTO; - 'authToken'?: string; - 'cpuQuota'?: number; - 'entrypoint'?: Array; - 'env'?: { [key: string]: string; }; - 'fromVolumeId'?: string; - 'gpuQuota'?: number; - 'id': string; - 'image': string; - 'memoryQuota'?: number; - 'metadata'?: { [key: string]: string; }; - 'networkAllowList'?: string; - 'networkBlockAll'?: boolean; - /** - * Nullable for backward compatibility - */ - 'organizationId'?: string; - 'osUser': string; - 'otelEndpoint'?: string; - 'regionId'?: string; - 'registry'?: RegistryDTO; - 'skipStart'?: boolean; - 'storageQuota'?: number; - 'volumes'?: Array; -} diff --git a/apps/libs/runner-api-client/src/models/index.ts b/apps/libs/runner-api-client/src/models/index.ts index 4d1f184bf..cce139281 100644 --- a/apps/libs/runner-api-client/src/models/index.ts +++ b/apps/libs/runner-api-client/src/models/index.ts @@ -3,7 +3,6 @@ export * from './box-info-response'; export * from './build-snapshot-request-dto'; export * from './create-backup-dto'; export * from './create-box-dto'; -export * from './create-box-with-capabilities-dto'; export * from './container-capabilities-dto'; export * from './dto-volume-dto'; export * from './enums-backup-state'; @@ -14,7 +13,6 @@ export * from './is-recoverable-dto'; export * from './is-recoverable-response'; export * from './pull-snapshot-request-dto'; export * from './recover-box-dto'; -export * from './recover-box-with-capabilities-dto'; export * from './registry-dto'; export * from './runner-info-response-dto'; export * from './runner-metrics'; diff --git a/apps/libs/runner-api-client/src/models/recover-box-dto.ts b/apps/libs/runner-api-client/src/models/recover-box-dto.ts index 0338486f3..3aa199fe2 100644 --- a/apps/libs/runner-api-client/src/models/recover-box-dto.ts +++ b/apps/libs/runner-api-client/src/models/recover-box-dto.ts @@ -13,11 +13,15 @@ */ +// May contain unused imports in some cases +// @ts-ignore +import type { AdvancedBoxOptionsDTO } from './advanced-box-options-dto'; // May contain unused imports in some cases // @ts-ignore import type { DtoVolumeDTO } from './dto-volume-dto'; export interface RecoverBoxDTO { + 'advanced'?: AdvancedBoxOptionsDTO; 'backupErrorReason'?: string; 'cpuQuota'?: number; 'env'?: { [key: string]: string; }; diff --git a/apps/libs/runner-api-client/src/models/recover-box-with-capabilities-dto.ts b/apps/libs/runner-api-client/src/models/recover-box-with-capabilities-dto.ts deleted file mode 100644 index 1e0b5b1f1..000000000 --- a/apps/libs/runner-api-client/src/models/recover-box-with-capabilities-dto.ts +++ /dev/null @@ -1,36 +0,0 @@ -/* tslint:disable */ -/* eslint-disable */ -/** - * BoxLite Runner API - * BoxLite Runner API - * - * The version of the OpenAPI document: v0.0.0-dev - * - * - * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). - * https://openapi-generator.tech - * Do not edit the class manually. - */ - - -// May contain unused imports in some cases -// @ts-ignore -import type { AdvancedBoxOptionsDTO } from './advanced-box-options-dto'; -// May contain unused imports in some cases -// @ts-ignore -import type { DtoVolumeDTO } from './dto-volume-dto'; - -export interface RecoverBoxWithCapabilitiesDTO { - 'advanced': AdvancedBoxOptionsDTO; - 'cpuQuota'?: number; - 'env'?: { [key: string]: string; }; - 'errorReason': string; - 'fromVolumeId'?: string; - 'gpuQuota'?: number; - 'memoryQuota'?: number; - 'networkAllowList'?: string; - 'networkBlockAll'?: boolean; - 'osUser': string; - 'storageQuota'?: number; - 'volumes'?: Array; -} diff --git a/apps/runner/pkg/api/controllers/box.go b/apps/runner/pkg/api/controllers/box.go index 2917cae5d..51c049220 100644 --- a/apps/runner/pkg/api/controllers/box.go +++ b/apps/runner/pkg/api/controllers/box.go @@ -5,9 +5,6 @@ package controllers import ( - "encoding/json" - "errors" - "io" "net/http" "github.com/boxlite-ai/runner/pkg/api/dto" @@ -15,63 +12,10 @@ import ( "github.com/boxlite-ai/runner/pkg/models/enums" "github.com/boxlite-ai/runner/pkg/runner" "github.com/gin-gonic/gin" - "github.com/gin-gonic/gin/binding" common_errors "github.com/boxlite-ai/common-go/pkg/errors" ) -type legacyCreateBoxRequest struct { - dto.CreateBoxDTO - Advanced json.RawMessage `json:"advanced"` - // Retain fail-closed detection for requests produced by the short-lived - // flat capability contract during mixed-version rollouts. - CapAdd json.RawMessage `json:"capAdd"` - CapDrop json.RawMessage `json:"capDrop"` - CapAddSnake json.RawMessage `json:"cap_add"` - CapDropSnake json.RawMessage `json:"cap_drop"` -} - -type legacyRecoverBoxRequest struct { - dto.RecoverBoxDTO - Advanced json.RawMessage `json:"advanced"` - // Retain fail-closed detection for requests produced by the short-lived - // flat capability contract during mixed-version rollouts. - CapAdd json.RawMessage `json:"capAdd"` - CapDrop json.RawMessage `json:"capDrop"` - CapAddSnake json.RawMessage `json:"cap_add"` - CapDropSnake json.RawMessage `json:"cap_drop"` -} - -func hasCapabilityPolicyFields(fields ...json.RawMessage) bool { - for _, field := range fields { - if field != nil { - return true - } - } - return false -} - -func bindStrictJSON(ctx *gin.Context, target any) error { - decoder := json.NewDecoder(ctx.Request.Body) - decoder.DisallowUnknownFields() - if err := decoder.Decode(target); err != nil { - return err - } - - var extra any - if err := decoder.Decode(&extra); err != io.EOF { - if err == nil { - return errors.New("request body must contain a single JSON object") - } - return err - } - - if binding.Validator == nil { - return nil - } - return binding.Validator.ValidateStruct(target) -} - // Create godoc // // @Tags box @@ -89,62 +33,20 @@ func bindStrictJSON(ctx *gin.Context, target any) error { // // @id Create func Create(ctx *gin.Context) { - var request legacyCreateBoxRequest - err := ctx.ShouldBindJSON(&request) + var createBoxDto dto.CreateBoxDTO + err := ctx.ShouldBindJSON(&createBoxDto) if err != nil { ctx.Error(common_errors.NewInvalidBodyRequestError(err)) return } - if hasCapabilityPolicyFields( - request.Advanced, - request.CapAdd, - request.CapDrop, - request.CapAddSnake, - request.CapDropSnake, - ) { - ctx.Error(common_errors.NewInvalidBodyRequestError(errors.New("advanced capability policy requires POST /boxes/strict"))) - return - } - createBox(ctx, request.CreateBoxDTO) -} -// CreateWithCapabilities godoc -// -// @Tags box -// @Summary Create a box with a capability policy -// @Description Fail-closed create contract for capability-bearing requests -// @Param box body dto.CreateBoxWithCapabilitiesDTO true "Create box with capabilities" -// @Produce json -// @Success 201 {object} dto.StartBoxResponse -// @Failure 400 {object} common_errors.ErrorResponse -// @Failure 401 {object} common_errors.ErrorResponse -// @Failure 404 {object} common_errors.ErrorResponse -// @Failure 409 {object} common_errors.ErrorResponse -// @Failure 500 {object} common_errors.ErrorResponse -// @Router /boxes/strict [post] -// -// @id CreateWithCapabilities -func CreateWithCapabilities(ctx *gin.Context) { - var request dto.CreateBoxWithCapabilitiesDTO - if err := bindStrictJSON(ctx, &request); err != nil { - ctx.Error(common_errors.NewInvalidBodyRequestError(err)) - return - } - if !request.HasCapabilityPolicy() { - ctx.Error(common_errors.NewInvalidBodyRequestError(errors.New("advanced.capabilities.add or advanced.capabilities.drop is required"))) - return - } - createBox(ctx, request.AsCreateBoxDTO()) -} - -func createBox(ctx *gin.Context, createBoxDto dto.CreateBoxDTO) { - runnerInstance, err := runner.GetInstance(nil) + runner, err := runner.GetInstance(nil) if err != nil { ctx.Error(err) return } - _, daemonVersion, err := runnerInstance.Boxlite.Create(ctx.Request.Context(), createBoxDto) + _, daemonVersion, err := runner.Boxlite.Create(ctx.Request.Context(), createBoxDto) if err != nil { common.ContainerOperationCount.WithLabelValues("create", string(common.PrometheusOperationStatusFailure)).Inc() ctx.Error(err) @@ -165,12 +67,12 @@ func createBox(ctx *gin.Context, createBoxDto dto.CreateBoxDTO) { // @Description Destroy box // @Produce json // @Param boxId path string true "Box ID" -// @Success 200 {string} string "Box destroyed" -// @Failure 400 {object} common_errors.ErrorResponse -// @Failure 401 {object} common_errors.ErrorResponse -// @Failure 404 {object} common_errors.ErrorResponse -// @Failure 409 {object} common_errors.ErrorResponse -// @Failure 500 {object} common_errors.ErrorResponse +// @Success 200 {string} string "Box destroyed" +// @Failure 400 {object} common_errors.ErrorResponse +// @Failure 401 {object} common_errors.ErrorResponse +// @Failure 404 {object} common_errors.ErrorResponse +// @Failure 409 {object} common_errors.ErrorResponse +// @Failure 500 {object} common_errors.ErrorResponse // @Router /boxes/{boxId}/destroy [post] // // @id Destroy @@ -203,12 +105,12 @@ func Destroy(ctx *gin.Context) { // @Produce json // @Param boxId path string true "Box ID" // @Param box body dto.UpdateNetworkSettingsDTO true "Update network settings" -// @Success 200 {string} string "Network settings updated" -// @Failure 400 {object} common_errors.ErrorResponse -// @Failure 401 {object} common_errors.ErrorResponse -// @Failure 404 {object} common_errors.ErrorResponse -// @Failure 409 {object} common_errors.ErrorResponse -// @Failure 500 {object} common_errors.ErrorResponse +// @Success 200 {string} string "Network settings updated" +// @Failure 400 {object} common_errors.ErrorResponse +// @Failure 401 {object} common_errors.ErrorResponse +// @Failure 404 {object} common_errors.ErrorResponse +// @Failure 409 {object} common_errors.ErrorResponse +// @Failure 500 {object} common_errors.ErrorResponse // @Router /boxes/{boxId}/network-settings [post] // // @id UpdateNetworkSettings @@ -242,9 +144,9 @@ func UpdateNetworkSettings(ctx *gin.Context) { // @Summary Start box // @Description Start box // @Produce json -// @Param boxId path string true "Box ID" -// @Param metadata body object false "Metadata" -// @Param token query string false "Auth token" +// @Param boxId path string true "Box ID" +// @Param metadata body object false "Metadata" +// @Param token query string false "Auth token" // @Success 200 {object} dto.StartBoxResponse "Box started" // @Failure 400 {object} common_errors.ErrorResponse // @Failure 401 {object} common_errors.ErrorResponse @@ -293,14 +195,14 @@ func Start(ctx *gin.Context) { // @Summary Stop box // @Description Stop box // @Produce json -// @Param boxId path string true "Box ID" +// @Param boxId path string true "Box ID" // @Param box body dto.StopBoxDTO false "Stop box" -// @Success 200 {string} string "Box stopped" -// @Failure 400 {object} common_errors.ErrorResponse -// @Failure 401 {object} common_errors.ErrorResponse -// @Failure 404 {object} common_errors.ErrorResponse -// @Failure 409 {object} common_errors.ErrorResponse -// @Failure 500 {object} common_errors.ErrorResponse +// @Success 200 {string} string "Box stopped" +// @Failure 400 {object} common_errors.ErrorResponse +// @Failure 401 {object} common_errors.ErrorResponse +// @Failure 404 {object} common_errors.ErrorResponse +// @Failure 409 {object} common_errors.ErrorResponse +// @Failure 500 {object} common_errors.ErrorResponse // @Router /boxes/{boxId}/stop [post] // // @id Stop @@ -332,13 +234,13 @@ func Stop(ctx *gin.Context) { // @Summary Get box info // @Description Get box info // @Produce json -// @Param boxId path string true "Box ID" -// @Success 200 {object} BoxInfoResponse "Box info" -// @Failure 400 {object} common_errors.ErrorResponse -// @Failure 401 {object} common_errors.ErrorResponse -// @Failure 404 {object} common_errors.ErrorResponse -// @Failure 409 {object} common_errors.ErrorResponse -// @Failure 500 {object} common_errors.ErrorResponse +// @Param boxId path string true "Box ID" +// @Success 200 {object} BoxInfoResponse "Box info" +// @Failure 400 {object} common_errors.ErrorResponse +// @Failure 401 {object} common_errors.ErrorResponse +// @Failure 404 {object} common_errors.ErrorResponse +// @Failure 409 {object} common_errors.ErrorResponse +// @Failure 500 {object} common_errors.ErrorResponse // @Router /boxes/{boxId} [get] // // @id Info @@ -383,9 +285,9 @@ type BoxInfoResponse struct { // @Tags box // @Accept json // @Produce json -// @Param boxId path string true "Box ID" +// @Param boxId path string true "Box ID" // @Param recovery body dto.RecoverBoxDTO true "Recovery parameters" -// @Success 200 {string} string "Box recovered" +// @Success 200 {string} string "Box recovered" // @Failure 400 {object} common_errors.ErrorResponse // @Failure 401 {object} common_errors.ErrorResponse // @Failure 404 {object} common_errors.ErrorResponse @@ -395,65 +297,21 @@ type BoxInfoResponse struct { // // @id Recover func Recover(ctx *gin.Context) { - var request legacyRecoverBoxRequest - err := ctx.ShouldBindJSON(&request) + var recoverDto dto.RecoverBoxDTO + err := ctx.ShouldBindJSON(&recoverDto) if err != nil { ctx.Error(common_errors.NewInvalidBodyRequestError(err)) return } - if hasCapabilityPolicyFields( - request.Advanced, - request.CapAdd, - request.CapDrop, - request.CapAddSnake, - request.CapDropSnake, - ) { - ctx.Error(common_errors.NewInvalidBodyRequestError(errors.New("advanced capability policy requires the strict recovery endpoint"))) - return - } - recoverBox(ctx, request.RecoverBoxDTO) -} - -// RecoverWithCapabilities godoc -// -// @Summary Recover a box with a capability policy -// @Description Fail-closed recovery contract for capability-bearing requests -// @Tags box -// @Accept json -// @Produce json -// @Param boxId path string true "Box ID" -// @Param recovery body dto.RecoverBoxWithCapabilitiesDTO true "Recovery parameters with capabilities" -// @Success 200 {string} string "Box recovered" -// @Failure 400 {object} common_errors.ErrorResponse -// @Failure 401 {object} common_errors.ErrorResponse -// @Failure 404 {object} common_errors.ErrorResponse -// @Failure 409 {object} common_errors.ErrorResponse -// @Failure 500 {object} common_errors.ErrorResponse -// @Router /boxes/{boxId}/recover/strict [post] -// -// @id RecoverWithCapabilities -func RecoverWithCapabilities(ctx *gin.Context) { - var request dto.RecoverBoxWithCapabilitiesDTO - if err := bindStrictJSON(ctx, &request); err != nil { - ctx.Error(common_errors.NewInvalidBodyRequestError(err)) - return - } - if !request.HasCapabilityPolicy() { - ctx.Error(common_errors.NewInvalidBodyRequestError(errors.New("advanced.capabilities.add or advanced.capabilities.drop is required"))) - return - } - recoverBox(ctx, request.AsRecoverBoxDTO()) -} -func recoverBox(ctx *gin.Context, recoverDto dto.RecoverBoxDTO) { boxId := ctx.Param("boxId") - runnerInstance, err := runner.GetInstance(nil) + runner, err := runner.GetInstance(nil) if err != nil { ctx.Error(err) return } - err = runnerInstance.Boxlite.RecoverBox(ctx.Request.Context(), boxId, recoverDto) + err = runner.Boxlite.RecoverBox(ctx.Request.Context(), boxId, recoverDto) if err != nil { ctx.Error(err) return @@ -470,9 +328,9 @@ func recoverBox(ctx *gin.Context, recoverDto dto.RecoverBoxDTO) { // @Accept json // @Produce json // @Param boxId path string true "Box ID" -// @Param request body dto.IsRecoverableDTO true "Error reason to check" -// @Success 200 {object} dto.IsRecoverableResponse -// @Failure 400 {object} common_errors.ErrorResponse +// @Param request body dto.IsRecoverableDTO true "Error reason to check" +// @Success 200 {object} dto.IsRecoverableResponse +// @Failure 400 {object} common_errors.ErrorResponse // @Router /boxes/{boxId}/is-recoverable [post] // // @id IsRecoverable diff --git a/apps/runner/pkg/api/controllers/box_capabilities_test.go b/apps/runner/pkg/api/controllers/box_capabilities_test.go deleted file mode 100644 index 65c2055d3..000000000 --- a/apps/runner/pkg/api/controllers/box_capabilities_test.go +++ /dev/null @@ -1,231 +0,0 @@ -// Copyright 2026 BoxLite AI -// SPDX-License-Identifier: AGPL-3.0-only - -package controllers - -import ( - "net/http/httptest" - "strings" - "testing" - - "github.com/gin-gonic/gin" -) - -func capabilityRequestContext(t *testing.T, path string, payload string) *gin.Context { - t.Helper() - recorder := httptest.NewRecorder() - ctx, _ := gin.CreateTestContext(recorder) - ctx.Request = httptest.NewRequest("POST", path, strings.NewReader(payload)) - ctx.Request.Header.Set("Content-Type", "application/json") - ctx.Params = gin.Params{{Key: "boxId", Value: "box-1"}} - return ctx -} - -func TestLegacyHTTPContractsRejectCapabilityFields(t *testing.T) { - tests := []struct { - name string - path string - payload string - handler gin.HandlerFunc - }{ - { - name: "create", - path: "/boxes", - payload: `{"id":"box-1","image":"alpine:latest","osUser":"boxlite","advanced":{"capabilities":{"add":["SYS_ADMIN"]}}}`, - handler: Create, - }, - { - name: "recover", - path: "/boxes/box-1/recover", - payload: `{"osUser":"boxlite","errorReason":"retry","advanced":{"capabilities":{"drop":["NET_RAW"]}}}`, - handler: Recover, - }, - { - name: "create empty advanced field", - path: "/boxes", - payload: `{"id":"box-1","image":"alpine:latest","osUser":"boxlite","advanced":{}}`, - handler: Create, - }, - { - name: "recover null advanced field", - path: "/boxes/box-1/recover", - payload: `{"osUser":"boxlite","errorReason":"retry","advanced":null}`, - handler: Recover, - }, - { - name: "create empty capabilities field", - path: "/boxes", - payload: `{"id":"box-1","image":"alpine:latest","osUser":"boxlite","advanced":{"capabilities":{}}}`, - handler: Create, - }, - { - name: "recover null capabilities field", - path: "/boxes/box-1/recover", - payload: `{"osUser":"boxlite","errorReason":"retry","advanced":{"capabilities":null}}`, - handler: Recover, - }, - { - name: "create old flat policy field", - path: "/boxes", - payload: `{"id":"box-1","image":"alpine:latest","osUser":"boxlite","capAdd":["SYS_ADMIN"]}`, - handler: Create, - }, - { - name: "create snake case flat policy field", - path: "/boxes", - payload: `{"id":"box-1","image":"alpine:latest","osUser":"boxlite","cap_add":["SYS_ADMIN"]}`, - handler: Create, - }, - { - name: "recover snake case flat policy field", - path: "/boxes/box-1/recover", - payload: `{"osUser":"boxlite","errorReason":"retry","cap_drop":["NET_RAW"]}`, - handler: Recover, - }, - } - - for _, test := range tests { - t.Run(test.name, func(t *testing.T) { - ctx := capabilityRequestContext(t, test.path, test.payload) - test.handler(ctx) - - if len(ctx.Errors) == 0 || !strings.Contains(ctx.Errors.Last().Error(), "capability") { - t.Fatalf("expected explicit capability contract error, got %v", ctx.Errors) - } - }) - } -} - -func TestStrictHTTPContractsAcceptOneSidedCapabilityPolicies(t *testing.T) { - tests := []struct { - name string - path string - payload string - handler gin.HandlerFunc - }{ - { - name: "create add only", - path: "/boxes/strict", - payload: `{"id":"box-1","image":"alpine:latest","osUser":"boxlite","advanced":{"capabilities":{"add":["SYS_ADMIN"]}}}`, - handler: CreateWithCapabilities, - }, - { - name: "create drop only", - path: "/boxes/strict", - payload: `{"id":"box-1","image":"alpine:latest","osUser":"boxlite","advanced":{"capabilities":{"drop":["NET_RAW"]}}}`, - handler: CreateWithCapabilities, - }, - { - name: "recover add only", - path: "/boxes/box-1/recover/strict", - payload: `{"osUser":"boxlite","errorReason":"retry","advanced":{"capabilities":{"add":["SYS_ADMIN"]}}}`, - handler: RecoverWithCapabilities, - }, - { - name: "recover drop only", - path: "/boxes/box-1/recover/strict", - payload: `{"osUser":"boxlite","errorReason":"retry","advanced":{"capabilities":{"drop":["NET_RAW"]}}}`, - handler: RecoverWithCapabilities, - }, - } - - for _, test := range tests { - t.Run(test.name, func(t *testing.T) { - ctx := capabilityRequestContext(t, test.path, test.payload) - test.handler(ctx) - - if len(ctx.Errors) > 0 && strings.Contains(ctx.Errors.Last().Error(), "invalid request body") { - t.Fatalf("one-sided capability policy was rejected: %v", ctx.Errors.Last()) - } - }) - } -} - -func TestStrictHTTPContractsRejectUnknownFields(t *testing.T) { - tests := []struct { - name string - path string - payload string - handler gin.HandlerFunc - }{ - { - name: "create top-level", - path: "/boxes/strict", - payload: `{"id":"box-1","image":"alpine:latest","osUser":"boxlite","advanced":{"capabilities":{"add":["SYS_ADMIN"]}},"futureSecurityOption":true}`, - handler: CreateWithCapabilities, - }, - { - name: "create advanced", - path: "/boxes/strict", - payload: `{"id":"box-1","image":"alpine:latest","osUser":"boxlite","advanced":{"capabilities":{"add":["SYS_ADMIN"]},"futureSecurityOption":true}}`, - handler: CreateWithCapabilities, - }, - { - name: "recover capabilities", - path: "/boxes/box-1/recover/strict", - payload: `{"osUser":"boxlite","errorReason":"retry","advanced":{"capabilities":{"drop":["NET_RAW"],"futureCapabilityOption":true}}}`, - handler: RecoverWithCapabilities, - }, - } - - for _, test := range tests { - t.Run(test.name, func(t *testing.T) { - ctx := capabilityRequestContext(t, test.path, test.payload) - test.handler(ctx) - - if len(ctx.Errors) == 0 || !strings.Contains(ctx.Errors.Last().Error(), "unknown field") { - t.Fatalf("expected unknown-field rejection, got %v", ctx.Errors) - } - }) - } -} - -func TestStrictHTTPContractsRejectNullCapabilityFields(t *testing.T) { - tests := []struct { - name string - path string - payload string - handler gin.HandlerFunc - want string - }{ - { - name: "create null advanced", - path: "/boxes/strict", - payload: `{"id":"box-1","image":"alpine:latest","osUser":"boxlite","advanced":null}`, - handler: CreateWithCapabilities, - want: "advanced", - }, - { - name: "recover null capabilities", - path: "/boxes/box-1/recover/strict", - payload: `{"osUser":"boxlite","errorReason":"retry","advanced":{"capabilities":null}}`, - handler: RecoverWithCapabilities, - want: "capabilities", - }, - { - name: "create null add", - path: "/boxes/strict", - payload: `{"id":"box-1","image":"alpine:latest","osUser":"boxlite","advanced":{"capabilities":{"add":null,"drop":["NET_RAW"]}}}`, - handler: CreateWithCapabilities, - want: "add must not be null", - }, - { - name: "recover null drop", - path: "/boxes/box-1/recover/strict", - payload: `{"osUser":"boxlite","errorReason":"retry","advanced":{"capabilities":{"add":["SYS_ADMIN"],"drop":null}}}`, - handler: RecoverWithCapabilities, - want: "drop must not be null", - }, - } - - for _, test := range tests { - t.Run(test.name, func(t *testing.T) { - ctx := capabilityRequestContext(t, test.path, test.payload) - test.handler(ctx) - - if len(ctx.Errors) == 0 || !strings.Contains(ctx.Errors.Last().Error(), test.want) { - t.Fatalf("expected null-field rejection, got %v", ctx.Errors) - } - }) - } -} diff --git a/apps/runner/pkg/api/docs/docs.go b/apps/runner/pkg/api/docs/docs.go index 12b1e3326..b6bacde3b 100644 --- a/apps/runner/pkg/api/docs/docs.go +++ b/apps/runner/pkg/api/docs/docs.go @@ -98,68 +98,6 @@ const docTemplate = `{ } } }, - "/boxes/strict": { - "post": { - "description": "Fail-closed create contract for capability-bearing requests", - "produces": [ - "application/json" - ], - "tags": [ - "box" - ], - "summary": "Create a box with a capability policy", - "operationId": "CreateWithCapabilities", - "parameters": [ - { - "description": "Create box with capabilities", - "name": "box", - "in": "body", - "required": true, - "schema": { - "$ref": "#/definitions/CreateBoxWithCapabilitiesDTO" - } - } - ], - "responses": { - "201": { - "description": "Created", - "schema": { - "$ref": "#/definitions/StartBoxResponse" - } - }, - "400": { - "description": "Bad Request", - "schema": { - "$ref": "#/definitions/ErrorResponse" - } - }, - "401": { - "description": "Unauthorized", - "schema": { - "$ref": "#/definitions/ErrorResponse" - } - }, - "404": { - "description": "Not Found", - "schema": { - "$ref": "#/definitions/ErrorResponse" - } - }, - "409": { - "description": "Conflict", - "schema": { - "$ref": "#/definitions/ErrorResponse" - } - }, - "500": { - "description": "Internal Server Error", - "schema": { - "$ref": "#/definitions/ErrorResponse" - } - } - } - } - }, "/boxes/{boxId}": { "get": { "description": "Get box info", @@ -538,78 +476,6 @@ const docTemplate = `{ } } }, - "/boxes/{boxId}/recover/strict": { - "post": { - "description": "Fail-closed recovery contract for capability-bearing requests", - "consumes": [ - "application/json" - ], - "produces": [ - "application/json" - ], - "tags": [ - "box" - ], - "summary": "Recover a box with a capability policy", - "operationId": "RecoverWithCapabilities", - "parameters": [ - { - "type": "string", - "description": "Box ID", - "name": "boxId", - "in": "path", - "required": true - }, - { - "description": "Recovery parameters with capabilities", - "name": "recovery", - "in": "body", - "required": true, - "schema": { - "$ref": "#/definitions/RecoverBoxWithCapabilitiesDTO" - } - } - ], - "responses": { - "200": { - "description": "Box recovered", - "schema": { - "type": "string" - } - }, - "400": { - "description": "Bad Request", - "schema": { - "$ref": "#/definitions/ErrorResponse" - } - }, - "401": { - "description": "Unauthorized", - "schema": { - "$ref": "#/definitions/ErrorResponse" - } - }, - "404": { - "description": "Not Found", - "schema": { - "$ref": "#/definitions/ErrorResponse" - } - }, - "409": { - "description": "Conflict", - "schema": { - "$ref": "#/definitions/ErrorResponse" - } - }, - "500": { - "description": "Internal Server Error", - "schema": { - "$ref": "#/definitions/ErrorResponse" - } - } - } - } - }, "/boxes/{boxId}/start": { "post": { "description": "Start box", @@ -1381,95 +1247,6 @@ const docTemplate = `{ "osUser", "image" ], - "properties": { - "authToken": { - "type": "string" - }, - "cpuQuota": { - "type": "integer", - "minimum": 1 - }, - "entrypoint": { - "type": "array", - "items": { - "type": "string" - } - }, - "env": { - "type": "object", - "additionalProperties": { - "type": "string" - } - }, - "fromVolumeId": { - "type": "string" - }, - "gpuQuota": { - "type": "integer", - "minimum": 0 - }, - "id": { - "type": "string" - }, - "memoryQuota": { - "type": "integer", - "minimum": 1 - }, - "metadata": { - "type": "object", - "additionalProperties": { - "type": "string" - } - }, - "networkAllowList": { - "type": "string" - }, - "networkBlockAll": { - "type": "boolean" - }, - "organizationId": { - "description": "Nullable for backward compatibility", - "type": "string" - }, - "osUser": { - "type": "string" - }, - "otelEndpoint": { - "type": "string" - }, - "regionId": { - "type": "string" - }, - "registry": { - "$ref": "#/definitions/RegistryDTO" - }, - "skipStart": { - "type": "boolean" - }, - "image": { - "type": "string" - }, - "storageQuota": { - "type": "integer", - "minimum": 1 - }, - "volumes": { - "type": "array", - "items": { - "$ref": "#/definitions/dto.VolumeDTO" - } - } - } - }, - "CreateBoxWithCapabilitiesDTO": { - "type": "object", - "required": [ - "advanced", - "id", - "image", - "osUser" - ], - "additionalProperties": false, "properties": { "advanced": { "$ref": "#/definitions/AdvancedBoxOptionsDTO" @@ -1503,9 +1280,6 @@ const docTemplate = `{ "id": { "type": "string" }, - "image": { - "type": "string" - }, "memoryQuota": { "type": "integer", "minimum": 1 @@ -1541,6 +1315,9 @@ const docTemplate = `{ "skipStart": { "type": "boolean" }, + "image": { + "type": "string" + }, "storageQuota": { "type": "integer", "minimum": 1 @@ -1671,6 +1448,9 @@ const docTemplate = `{ "osUser" ], "properties": { + "advanced": { + "$ref": "#/definitions/AdvancedBoxOptionsDTO" + }, "backupErrorReason": { "type": "string" }, @@ -1722,63 +1502,6 @@ const docTemplate = `{ } } }, - "RecoverBoxWithCapabilitiesDTO": { - "type": "object", - "required": [ - "advanced", - "errorReason", - "osUser" - ], - "additionalProperties": false, - "properties": { - "advanced": { - "$ref": "#/definitions/AdvancedBoxOptionsDTO" - }, - "cpuQuota": { - "type": "integer", - "minimum": 1 - }, - "env": { - "type": "object", - "additionalProperties": { - "type": "string" - } - }, - "errorReason": { - "type": "string" - }, - "fromVolumeId": { - "type": "string" - }, - "gpuQuota": { - "type": "integer", - "minimum": 0 - }, - "memoryQuota": { - "type": "integer", - "minimum": 1 - }, - "networkAllowList": { - "type": "string" - }, - "networkBlockAll": { - "type": "boolean" - }, - "osUser": { - "type": "string" - }, - "storageQuota": { - "type": "integer", - "minimum": 1 - }, - "volumes": { - "type": "array", - "items": { - "$ref": "#/definitions/dto.VolumeDTO" - } - } - } - }, "RegistryDTO": { "type": "object", "required": [ diff --git a/apps/runner/pkg/api/docs/swagger.json b/apps/runner/pkg/api/docs/swagger.json index 6e92a29d9..ad2929377 100644 --- a/apps/runner/pkg/api/docs/swagger.json +++ b/apps/runner/pkg/api/docs/swagger.json @@ -84,64 +84,6 @@ } } }, - "/boxes/strict": { - "post": { - "description": "Fail-closed create contract for capability-bearing requests", - "produces": ["application/json"], - "tags": ["box"], - "summary": "Create a box with a capability policy", - "operationId": "CreateWithCapabilities", - "parameters": [ - { - "description": "Create box with capabilities", - "name": "box", - "in": "body", - "required": true, - "schema": { - "$ref": "#/definitions/CreateBoxWithCapabilitiesDTO" - } - } - ], - "responses": { - "201": { - "description": "Created", - "schema": { - "$ref": "#/definitions/StartBoxResponse" - } - }, - "400": { - "description": "Bad Request", - "schema": { - "$ref": "#/definitions/ErrorResponse" - } - }, - "401": { - "description": "Unauthorized", - "schema": { - "$ref": "#/definitions/ErrorResponse" - } - }, - "404": { - "description": "Not Found", - "schema": { - "$ref": "#/definitions/ErrorResponse" - } - }, - "409": { - "description": "Conflict", - "schema": { - "$ref": "#/definitions/ErrorResponse" - } - }, - "500": { - "description": "Internal Server Error", - "schema": { - "$ref": "#/definitions/ErrorResponse" - } - } - } - } - }, "/boxes/{boxId}": { "get": { "description": "Get box info", @@ -492,72 +434,6 @@ } } }, - "/boxes/{boxId}/recover/strict": { - "post": { - "description": "Fail-closed recovery contract for capability-bearing requests", - "consumes": ["application/json"], - "produces": ["application/json"], - "tags": ["box"], - "summary": "Recover a box with a capability policy", - "operationId": "RecoverWithCapabilities", - "parameters": [ - { - "type": "string", - "description": "Box ID", - "name": "boxId", - "in": "path", - "required": true - }, - { - "description": "Recovery parameters with capabilities", - "name": "recovery", - "in": "body", - "required": true, - "schema": { - "$ref": "#/definitions/RecoverBoxWithCapabilitiesDTO" - } - } - ], - "responses": { - "200": { - "description": "Box recovered", - "schema": { - "type": "string" - } - }, - "400": { - "description": "Bad Request", - "schema": { - "$ref": "#/definitions/ErrorResponse" - } - }, - "401": { - "description": "Unauthorized", - "schema": { - "$ref": "#/definitions/ErrorResponse" - } - }, - "404": { - "description": "Not Found", - "schema": { - "$ref": "#/definitions/ErrorResponse" - } - }, - "409": { - "description": "Conflict", - "schema": { - "$ref": "#/definitions/ErrorResponse" - } - }, - "500": { - "description": "Internal Server Error", - "schema": { - "$ref": "#/definitions/ErrorResponse" - } - } - } - } - }, "/boxes/{boxId}/start": { "post": { "description": "Start box", @@ -1281,90 +1157,6 @@ "CreateBoxDTO": { "type": "object", "required": ["id", "osUser", "image"], - "properties": { - "authToken": { - "type": "string" - }, - "cpuQuota": { - "type": "integer", - "minimum": 1 - }, - "entrypoint": { - "type": "array", - "items": { - "type": "string" - } - }, - "env": { - "type": "object", - "additionalProperties": { - "type": "string" - } - }, - "fromVolumeId": { - "type": "string" - }, - "gpuQuota": { - "type": "integer", - "minimum": 0 - }, - "id": { - "type": "string" - }, - "memoryQuota": { - "type": "integer", - "minimum": 1 - }, - "metadata": { - "type": "object", - "additionalProperties": { - "type": "string" - } - }, - "networkAllowList": { - "type": "string" - }, - "networkBlockAll": { - "type": "boolean" - }, - "organizationId": { - "description": "Nullable for backward compatibility", - "type": "string" - }, - "osUser": { - "type": "string" - }, - "otelEndpoint": { - "type": "string" - }, - "regionId": { - "type": "string" - }, - "registry": { - "$ref": "#/definitions/RegistryDTO" - }, - "skipStart": { - "type": "boolean" - }, - "image": { - "type": "string" - }, - "storageQuota": { - "type": "integer", - "minimum": 1 - }, - "volumes": { - "type": "array", - "items": { - "$ref": "#/definitions/dto.VolumeDTO" - } - } - } - }, - "CreateBoxWithCapabilitiesDTO": { - "type": "object", - "required": ["advanced", "id", "image", "osUser"], - "additionalProperties": false, "properties": { "advanced": { "$ref": "#/definitions/AdvancedBoxOptionsDTO" @@ -1398,9 +1190,6 @@ "id": { "type": "string" }, - "image": { - "type": "string" - }, "memoryQuota": { "type": "integer", "minimum": 1 @@ -1436,6 +1225,9 @@ "skipStart": { "type": "boolean" }, + "image": { + "type": "string" + }, "storageQuota": { "type": "integer", "minimum": 1 @@ -1552,6 +1344,9 @@ "type": "object", "required": ["errorReason", "osUser"], "properties": { + "advanced": { + "$ref": "#/definitions/AdvancedBoxOptionsDTO" + }, "backupErrorReason": { "type": "string" }, @@ -1603,59 +1398,6 @@ } } }, - "RecoverBoxWithCapabilitiesDTO": { - "type": "object", - "required": ["advanced", "errorReason", "osUser"], - "additionalProperties": false, - "properties": { - "advanced": { - "$ref": "#/definitions/AdvancedBoxOptionsDTO" - }, - "cpuQuota": { - "type": "integer", - "minimum": 1 - }, - "env": { - "type": "object", - "additionalProperties": { - "type": "string" - } - }, - "errorReason": { - "type": "string" - }, - "fromVolumeId": { - "type": "string" - }, - "gpuQuota": { - "type": "integer", - "minimum": 0 - }, - "memoryQuota": { - "type": "integer", - "minimum": 1 - }, - "networkAllowList": { - "type": "string" - }, - "networkBlockAll": { - "type": "boolean" - }, - "osUser": { - "type": "string" - }, - "storageQuota": { - "type": "integer", - "minimum": 1 - }, - "volumes": { - "type": "array", - "items": { - "$ref": "#/definitions/dto.VolumeDTO" - } - } - } - }, "RegistryDTO": { "type": "object", "required": ["url"], diff --git a/apps/runner/pkg/api/docs/swagger.yaml b/apps/runner/pkg/api/docs/swagger.yaml index 16442974a..cfd191c45 100644 --- a/apps/runner/pkg/api/docs/swagger.yaml +++ b/apps/runner/pkg/api/docs/swagger.yaml @@ -57,6 +57,8 @@ definitions: type: object CreateBoxDTO: properties: + advanced: + $ref: '#/definitions/AdvancedBoxOptionsDTO' authToken: type: string cpuQuota: @@ -115,70 +117,6 @@ definitions: - osUser - image type: object - CreateBoxWithCapabilitiesDTO: - additionalProperties: false - properties: - advanced: - $ref: '#/definitions/AdvancedBoxOptionsDTO' - authToken: - type: string - cpuQuota: - minimum: 1 - type: integer - entrypoint: - items: - type: string - type: array - env: - additionalProperties: - type: string - type: object - fromVolumeId: - type: string - gpuQuota: - minimum: 0 - type: integer - id: - type: string - image: - type: string - memoryQuota: - minimum: 1 - type: integer - metadata: - additionalProperties: - type: string - type: object - networkAllowList: - type: string - networkBlockAll: - type: boolean - organizationId: - description: Nullable for backward compatibility - type: string - osUser: - type: string - otelEndpoint: - type: string - regionId: - type: string - registry: - $ref: '#/definitions/RegistryDTO' - skipStart: - type: boolean - storageQuota: - minimum: 1 - type: integer - volumes: - items: - $ref: '#/definitions/dto.VolumeDTO' - type: array - required: - - advanced - - id - - image - - osUser - type: object ContainerCapabilitiesDTO: additionalProperties: false properties: @@ -257,6 +195,8 @@ definitions: type: object RecoverBoxDTO: properties: + advanced: + $ref: '#/definitions/AdvancedBoxOptionsDTO' backupErrorReason: type: string cpuQuota: @@ -295,46 +235,6 @@ definitions: - errorReason - osUser type: object - RecoverBoxWithCapabilitiesDTO: - additionalProperties: false - properties: - advanced: - $ref: '#/definitions/AdvancedBoxOptionsDTO' - cpuQuota: - minimum: 1 - type: integer - env: - additionalProperties: - type: string - type: object - errorReason: - type: string - fromVolumeId: - type: string - gpuQuota: - minimum: 0 - type: integer - memoryQuota: - minimum: 1 - type: integer - networkAllowList: - type: string - networkBlockAll: - type: boolean - osUser: - type: string - storageQuota: - minimum: 1 - type: integer - volumes: - items: - $ref: '#/definitions/dto.VolumeDTO' - type: array - required: - - advanced - - errorReason - - osUser - type: object RegistryDTO: properties: password: @@ -580,47 +480,6 @@ paths: summary: Create a box tags: - box - /boxes/strict: - post: - description: Fail-closed create contract for capability-bearing requests - operationId: CreateWithCapabilities - parameters: - - description: Create box with capabilities - in: body - name: box - required: true - schema: - $ref: '#/definitions/CreateBoxWithCapabilitiesDTO' - produces: - - application/json - responses: - '201': - description: Created - schema: - $ref: '#/definitions/StartBoxResponse' - '400': - description: Bad Request - schema: - $ref: '#/definitions/ErrorResponse' - '401': - description: Unauthorized - schema: - $ref: '#/definitions/ErrorResponse' - '404': - description: Not Found - schema: - $ref: '#/definitions/ErrorResponse' - '409': - description: Conflict - schema: - $ref: '#/definitions/ErrorResponse' - '500': - description: Internal Server Error - schema: - $ref: '#/definitions/ErrorResponse' - summary: Create a box with a capability policy - tags: - - box /boxes/{boxId}: get: description: Get box info @@ -873,54 +732,6 @@ paths: summary: Recover box from error state tags: - box - /boxes/{boxId}/recover/strict: - post: - consumes: - - application/json - description: Fail-closed recovery contract for capability-bearing requests - operationId: RecoverWithCapabilities - parameters: - - description: Box ID - in: path - name: boxId - required: true - type: string - - description: Recovery parameters with capabilities - in: body - name: recovery - required: true - schema: - $ref: '#/definitions/RecoverBoxWithCapabilitiesDTO' - produces: - - application/json - responses: - '200': - description: Box recovered - schema: - type: string - '400': - description: Bad Request - schema: - $ref: '#/definitions/ErrorResponse' - '401': - description: Unauthorized - schema: - $ref: '#/definitions/ErrorResponse' - '404': - description: Not Found - schema: - $ref: '#/definitions/ErrorResponse' - '409': - description: Conflict - schema: - $ref: '#/definitions/ErrorResponse' - '500': - description: Internal Server Error - schema: - $ref: '#/definitions/ErrorResponse' - summary: Recover a box with a capability policy - tags: - - box /boxes/{boxId}/start: post: description: Start box diff --git a/apps/runner/pkg/api/dto/box.go b/apps/runner/pkg/api/dto/box.go index 847903320..30499d553 100644 --- a/apps/runner/pkg/api/dto/box.go +++ b/apps/runner/pkg/api/dto/box.go @@ -4,12 +4,6 @@ package dto -import ( - "bytes" - "encoding/json" - "fmt" -) - type CreateBoxDTO struct { Id string `json:"id" validate:"required"` FromVolumeId string `json:"fromVolumeId,omitempty"` @@ -34,97 +28,18 @@ type CreateBoxDTO struct { OrganizationId *string `json:"organizationId,omitempty"` RegionId *string `json:"regionId,omitempty"` - // Advanced is execution-only on this legacy wire DTO. Capability-bearing - // requests use CreateBoxWithCapabilitiesDTO so old endpoints cannot silently - // discard policy fields they do not understand. - Advanced *AdvancedBoxOptionsDTO `json:"-" swaggerignore:"true"` + Advanced *AdvancedBoxOptionsDTO `json:"advanced,omitempty"` } // @name CreateBoxDTO -type CreateBoxWithCapabilitiesDTO struct { - CreateBoxDTO - Advanced *AdvancedBoxOptionsDTO `json:"advanced" validate:"required"` -} // @name CreateBoxWithCapabilitiesDTO - -func (d CreateBoxWithCapabilitiesDTO) HasCapabilityPolicy() bool { - return d.Advanced != nil && d.Advanced.HasCapabilityPolicy() -} - -func (d CreateBoxWithCapabilitiesDTO) AsCreateBoxDTO() CreateBoxDTO { - request := d.CreateBoxDTO - request.Advanced = d.Advanced.Clone() - return request -} - type AdvancedBoxOptionsDTO struct { - Capabilities *ContainerCapabilitiesDTO `json:"capabilities" validate:"required"` + Capabilities *ContainerCapabilitiesDTO `json:"capabilities,omitempty"` } // @name AdvancedBoxOptionsDTO -func (d *AdvancedBoxOptionsDTO) HasCapabilityPolicy() bool { - return d != nil && d.Capabilities != nil && !d.Capabilities.IsEmpty() -} - -func (d *AdvancedBoxOptionsDTO) Clone() *AdvancedBoxOptionsDTO { - if d == nil { - return nil - } - - clone := &AdvancedBoxOptionsDTO{} - if d.Capabilities != nil { - clone.Capabilities = &ContainerCapabilitiesDTO{ - Add: append([]string(nil), d.Capabilities.Add...), - Drop: append([]string(nil), d.Capabilities.Drop...), - } - } - return clone -} - type ContainerCapabilitiesDTO struct { - Add []string `json:"add,omitempty" validate:"omitempty,dive,required"` - Drop []string `json:"drop,omitempty" validate:"omitempty,dive,required"` + Add []string `json:"add,omitempty"` + Drop []string `json:"drop,omitempty"` } // @name ContainerCapabilitiesDTO -func (d *ContainerCapabilitiesDTO) UnmarshalJSON(data []byte) error { - type containerCapabilitiesWire struct { - Add json.RawMessage `json:"add"` - Drop json.RawMessage `json:"drop"` - } - - decoder := json.NewDecoder(bytes.NewReader(data)) - decoder.DisallowUnknownFields() - var wire containerCapabilitiesWire - if err := decoder.Decode(&wire); err != nil { - return err - } - - add, err := decodeCapabilityList("add", wire.Add) - if err != nil { - return err - } - drop, err := decodeCapabilityList("drop", wire.Drop) - if err != nil { - return err - } - - d.Add = add - d.Drop = drop - return nil -} - -func decodeCapabilityList(field string, raw json.RawMessage) ([]string, error) { - if raw == nil { - return nil, nil - } - if bytes.Equal(bytes.TrimSpace(raw), []byte("null")) { - return nil, fmt.Errorf("advanced.capabilities.%s must not be null", field) - } - - var capabilities []string - if err := json.Unmarshal(raw, &capabilities); err != nil { - return nil, fmt.Errorf("decode advanced.capabilities.%s: %w", field, err) - } - return capabilities, nil -} - func (d *ContainerCapabilitiesDTO) IsEmpty() bool { return d == nil || (len(d.Add) == 0 && len(d.Drop) == 0) } @@ -148,25 +63,9 @@ type RecoverBoxDTO struct { NetworkAllowList *string `json:"networkAllowList,omitempty"` ErrorReason string `json:"errorReason" validate:"required"` - // Advanced is populated only after decoding the strict wire DTO. - Advanced *AdvancedBoxOptionsDTO `json:"-" swaggerignore:"true"` + Advanced *AdvancedBoxOptionsDTO `json:"advanced,omitempty"` } // @name RecoverBoxDTO -type RecoverBoxWithCapabilitiesDTO struct { - RecoverBoxDTO - Advanced *AdvancedBoxOptionsDTO `json:"advanced" validate:"required"` -} // @name RecoverBoxWithCapabilitiesDTO - -func (d RecoverBoxWithCapabilitiesDTO) HasCapabilityPolicy() bool { - return d.Advanced != nil && d.Advanced.HasCapabilityPolicy() -} - -func (d RecoverBoxWithCapabilitiesDTO) AsRecoverBoxDTO() RecoverBoxDTO { - request := d.RecoverBoxDTO - request.Advanced = d.Advanced.Clone() - return request -} - type IsRecoverableDTO struct { ErrorReason string `json:"errorReason" validate:"required"` } // @name IsRecoverableDTO diff --git a/apps/runner/pkg/api/dto/box_capabilities_test.go b/apps/runner/pkg/api/dto/box_capabilities_test.go index 9e599a290..66edc0edf 100644 --- a/apps/runner/pkg/api/dto/box_capabilities_test.go +++ b/apps/runner/pkg/api/dto/box_capabilities_test.go @@ -9,105 +9,52 @@ import ( "testing" ) -func TestLegacyCreateAndRecoverBoxDTOsDoNotSerializeCapabilities(t *testing.T) { - tests := []struct { - name string - request any - }{ - { - name: "create", - request: CreateBoxDTO{ - Advanced: capabilityTestAdvancedOptions(), - }, - }, - { - name: "recover", - request: RecoverBoxDTO{ - Advanced: capabilityTestAdvancedOptions(), - }, - }, - } - - for _, test := range tests { - t.Run(test.name, func(t *testing.T) { - encoded, err := json.Marshal(test.request) - if err != nil { - t.Fatalf("marshal legacy DTO: %v", err) - } - - var wire map[string]any - if err := json.Unmarshal(encoded, &wire); err != nil { - t.Fatalf("decode round-tripped payload: %v", err) - } - if _, ok := wire["advanced"]; ok { - t.Fatalf("legacy %s DTO serialized advanced options: %s", test.name, encoded) - } - }) - } +// The control plane and the runner agree on this wire shape; a rename or a +// dropped tag on either side would silently discard the privilege policy. +func TestBoxDTOsRoundTripCapabilityPolicy(t *testing.T) { + payload := `{"advanced":{"capabilities":{"add":["SYS_ADMIN"],"drop":["NET_RAW"]}}}` + + t.Run("create", func(t *testing.T) { + var request CreateBoxDTO + if err := json.Unmarshal([]byte(payload), &request); err != nil { + t.Fatalf("decode create payload: %v", err) + } + assertCapabilityPolicy(t, request.Advanced) + }) + + t.Run("recover", func(t *testing.T) { + var request RecoverBoxDTO + if err := json.Unmarshal([]byte(payload), &request); err != nil { + t.Fatalf("decode recover payload: %v", err) + } + assertCapabilityPolicy(t, request.Advanced) + }) } -func TestCapabilityCreateAndRecoverBoxDTOsPreserveCapabilities(t *testing.T) { - tests := []struct { - name string - decode func([]byte) ([]byte, error) - }{ - { - name: "create", - decode: func(payload []byte) ([]byte, error) { - var request CreateBoxWithCapabilitiesDTO - if err := json.Unmarshal(payload, &request); err != nil { - return nil, err - } - return json.Marshal(request) - }, - }, - { - name: "recover", - decode: func(payload []byte) ([]byte, error) { - var request RecoverBoxWithCapabilitiesDTO - if err := json.Unmarshal(payload, &request); err != nil { - return nil, err - } - return json.Marshal(request) - }, - }, +func TestBoxDTOsOmitAnUnsetCapabilityPolicy(t *testing.T) { + encoded, err := json.Marshal(CreateBoxDTO{}) + if err != nil { + t.Fatalf("marshal create DTO: %v", err) } - payload := []byte(`{"advanced":{"capabilities":{"add":["SYS_ADMIN"],"drop":["NET_RAW"]}}}`) - for _, test := range tests { - t.Run(test.name, func(t *testing.T) { - encoded, err := test.decode(payload) - if err != nil { - t.Fatalf("round trip capability payload: %v", err) - } - - var wire map[string]any - if err := json.Unmarshal(encoded, &wire); err != nil { - t.Fatalf("decode round-tripped payload: %v", err) - } - advanced, ok := wire["advanced"].(map[string]any) - if !ok { - t.Fatalf("advanced options lost across %s DTO: %s", test.name, encoded) - } - capabilities, ok := advanced["capabilities"].(map[string]any) - if !ok { - t.Fatalf("capabilities lost across %s DTO: %s", test.name, encoded) - } - if !reflect.DeepEqual(capabilities["add"], []any{"SYS_ADMIN"}) { - t.Fatalf("capabilities.add lost across %s DTO: %s", test.name, encoded) - } - if !reflect.DeepEqual(capabilities["drop"], []any{"NET_RAW"}) { - t.Fatalf("capabilities.drop lost across %s DTO: %s", test.name, encoded) - } - }) + var wire map[string]any + if err := json.Unmarshal(encoded, &wire); err != nil { + t.Fatalf("decode round-tripped payload: %v", err) + } + if _, ok := wire["advanced"]; ok { + t.Fatalf("create DTO serialized an unset advanced policy: %s", encoded) } } -func capabilityTestAdvancedOptions() *AdvancedBoxOptionsDTO { - return &AdvancedBoxOptionsDTO{ - Capabilities: &ContainerCapabilitiesDTO{ - Add: []string{"SYS_ADMIN"}, - Drop: []string{"NET_RAW"}, - }, +func assertCapabilityPolicy(t *testing.T, advanced *AdvancedBoxOptionsDTO) { + t.Helper() + if advanced == nil || advanced.Capabilities == nil { + t.Fatal("advanced.capabilities was dropped during decoding") + } + if !reflect.DeepEqual(advanced.Capabilities.Add, []string{"SYS_ADMIN"}) { + t.Fatalf("unexpected advanced.capabilities.add: %v", advanced.Capabilities.Add) + } + if !reflect.DeepEqual(advanced.Capabilities.Drop, []string{"NET_RAW"}) { + t.Fatalf("unexpected advanced.capabilities.drop: %v", advanced.Capabilities.Drop) } } diff --git a/apps/runner/pkg/api/server.go b/apps/runner/pkg/api/server.go index 5efeab5de..f53bfd675 100644 --- a/apps/runner/pkg/api/server.go +++ b/apps/runner/pkg/api/server.go @@ -132,13 +132,11 @@ func (a *ApiServer) Start(ctx context.Context) error { boxController := protected.Group("/boxes") { boxController.POST("", controllers.Create) - boxController.POST("/strict", controllers.CreateWithCapabilities) boxController.GET("/:boxId", controllers.Info) boxController.POST("/:boxId/destroy", controllers.Destroy) boxController.POST("/:boxId/start", controllers.Start) boxController.POST("/:boxId/stop", controllers.Stop) boxController.POST("/:boxId/recover", controllers.Recover) - boxController.POST("/:boxId/recover/strict", controllers.RecoverWithCapabilities) boxController.POST("/:boxId/is-recoverable", controllers.IsRecoverable) boxController.POST("/:boxId/network-settings", controllers.UpdateNetworkSettings) diff --git a/apps/runner/pkg/boxlite/stubs.go b/apps/runner/pkg/boxlite/stubs.go index d7c8269a4..072854221 100644 --- a/apps/runner/pkg/boxlite/stubs.go +++ b/apps/runner/pkg/boxlite/stubs.go @@ -27,7 +27,7 @@ func (c *Client) RecoverBox(ctx context.Context, boxId string, recoverDto dto.Re MemoryQuota: recoverDto.MemoryQuota, StorageQuota: recoverDto.StorageQuota, Env: recoverDto.Env, - Advanced: recoverDto.Advanced.Clone(), + Advanced: recoverDto.Advanced, Volumes: recoverDto.Volumes, NetworkBlockAll: recoverDto.NetworkBlockAll, NetworkAllowList: recoverDto.NetworkAllowList, diff --git a/apps/runner/pkg/runner/v2/executor/box.go b/apps/runner/pkg/runner/v2/executor/box.go index 59a867d81..cfc59828e 100644 --- a/apps/runner/pkg/runner/v2/executor/box.go +++ b/apps/runner/pkg/runner/v2/executor/box.go @@ -7,98 +7,21 @@ package executor import ( "context" - "encoding/json" "fmt" - "io" - "strings" apiclient "github.com/boxlite-ai/boxlite/libs/api-client-go" "github.com/boxlite-ai/runner/pkg/api/dto" "github.com/boxlite-ai/runner/pkg/common" "github.com/containerd/errdefs" - "github.com/go-playground/validator/v10" ) -var strictPayloadValidator = newStrictPayloadValidator() - -func newStrictPayloadValidator() *validator.Validate { - validate := validator.New(validator.WithRequiredStructEnabled()) - validate.SetTagName("validate") - _ = validate.RegisterValidation("optional", func(validator.FieldLevel) bool { - return true - }, true) - return validate -} - -func rejectLegacyCapabilityFields(payload *string, strictJobType apiclient.JobType) error { - if payload == nil || *payload == "" { - return nil - } - - var wireFields map[string]json.RawMessage - if err := json.Unmarshal([]byte(*payload), &wireFields); err != nil { - return nil - } - for field := range wireFields { - if strings.EqualFold(field, "advanced") || - strings.EqualFold(field, "capAdd") || - strings.EqualFold(field, "capDrop") || - strings.EqualFold(field, "cap_add") || - strings.EqualFold(field, "cap_drop") { - return fmt.Errorf("advanced capability policy requires %s job", strictJobType) - } - } - return nil -} - -func parseStrictPayload(payload *string, target any) error { - if payload == nil || *payload == "" { - return fmt.Errorf("payload is required") - } - - decoder := json.NewDecoder(strings.NewReader(*payload)) - decoder.DisallowUnknownFields() - if err := decoder.Decode(target); err != nil { - return err - } - - var extra any - if err := decoder.Decode(&extra); err != io.EOF { - if err == nil { - return fmt.Errorf("payload must contain a single JSON object") - } - return err - } - if err := strictPayloadValidator.Struct(target); err != nil { - return fmt.Errorf("validate payload: %w", err) - } - return nil -} - func (e *Executor) createBox(ctx context.Context, job *apiclient.Job) (any, error) { - if err := rejectLegacyCapabilityFields(job.Payload, apiclient.JOBTYPE_CREATE_BOX_WITH_CAPABILITIES_V2); err != nil { - return nil, err - } var createBoxDto dto.CreateBoxDTO err := e.parsePayload(job.Payload, &createBoxDto) if err != nil { return nil, fmt.Errorf("failed to unmarshal payload: %w", err) } - return e.executeCreateBox(ctx, createBoxDto) -} - -func (e *Executor) createBoxWithCapabilities(ctx context.Context, job *apiclient.Job) (any, error) { - var request dto.CreateBoxWithCapabilitiesDTO - if err := parseStrictPayload(job.Payload, &request); err != nil { - return nil, fmt.Errorf("failed to unmarshal payload: %w", err) - } - if !request.HasCapabilityPolicy() { - return nil, fmt.Errorf("capability create job requires advanced.capabilities.add or advanced.capabilities.drop") - } - return e.executeCreateBox(ctx, request.AsCreateBoxDTO()) -} -func (e *Executor) executeCreateBox(ctx context.Context, createBoxDto dto.CreateBoxDTO) (any, error) { _, daemonVersion, err := e.backend.Create(ctx, createBoxDto) if err != nil { common.ContainerOperationCount.WithLabelValues("create", string(common.PrometheusOperationStatusFailure)).Inc() @@ -166,30 +89,13 @@ func (e *Executor) updateNetworkSettings(ctx context.Context, job *apiclient.Job } func (e *Executor) recoverBox(ctx context.Context, job *apiclient.Job) (any, error) { - if err := rejectLegacyCapabilityFields(job.Payload, apiclient.JOBTYPE_RECOVER_BOX_WITH_CAPABILITIES_V2); err != nil { - return nil, err - } var recoverBoxDto dto.RecoverBoxDTO err := e.parsePayload(job.Payload, &recoverBoxDto) if err != nil { return nil, fmt.Errorf("failed to unmarshal payload: %w", err) } - return e.executeRecoverBox(ctx, job.ResourceId, recoverBoxDto) -} - -func (e *Executor) recoverBoxWithCapabilities(ctx context.Context, job *apiclient.Job) (any, error) { - var request dto.RecoverBoxWithCapabilitiesDTO - if err := parseStrictPayload(job.Payload, &request); err != nil { - return nil, fmt.Errorf("failed to unmarshal payload: %w", err) - } - if !request.HasCapabilityPolicy() { - return nil, fmt.Errorf("capability recovery job requires advanced.capabilities.add or advanced.capabilities.drop") - } - return e.executeRecoverBox(ctx, job.ResourceId, request.AsRecoverBoxDTO()) -} -func (e *Executor) executeRecoverBox(ctx context.Context, boxID string, recoverBoxDto dto.RecoverBoxDTO) (any, error) { - err := e.backend.RecoverBox(ctx, boxID, recoverBoxDto) + err = e.backend.RecoverBox(ctx, job.ResourceId, recoverBoxDto) if err != nil { return nil, common.FormatRecoverableError(err) } diff --git a/apps/runner/pkg/runner/v2/executor/box_capabilities_test.go b/apps/runner/pkg/runner/v2/executor/box_capabilities_test.go index 7319dde1a..55587bdd7 100644 --- a/apps/runner/pkg/runner/v2/executor/box_capabilities_test.go +++ b/apps/runner/pkg/runner/v2/executor/box_capabilities_test.go @@ -6,7 +6,6 @@ package executor import ( "context" "reflect" - "strings" "testing" apiclient "github.com/boxlite-ai/boxlite/libs/api-client-go" @@ -20,89 +19,6 @@ type capabilityCaptureBackend struct { recoverRequest *dto.RecoverBoxDTO } -func TestLegacyJobsRejectCapabilityFieldsBeforeBackend(t *testing.T) { - tests := []struct { - name string - jobType apiclient.JobType - payload string - }{ - { - name: "create", - jobType: apiclient.JOBTYPE_CREATE_BOX, - payload: `{"id":"box-1","image":"alpine:latest","osUser":"boxlite","advanced":{"capabilities":{"add":["SYS_ADMIN"]}}}`, - }, - { - name: "recover", - jobType: apiclient.JOBTYPE_RECOVER_BOX, - payload: `{"osUser":"boxlite","errorReason":"retry","advanced":{"capabilities":{"drop":["NET_RAW"]}}}`, - }, - { - name: "create empty advanced field", - jobType: apiclient.JOBTYPE_CREATE_BOX, - payload: `{"id":"box-1","image":"alpine:latest","osUser":"boxlite","advanced":{}}`, - }, - { - name: "recover null advanced field", - jobType: apiclient.JOBTYPE_RECOVER_BOX, - payload: `{"osUser":"boxlite","errorReason":"retry","advanced":null}`, - }, - { - name: "create empty capabilities field", - jobType: apiclient.JOBTYPE_CREATE_BOX, - payload: `{"id":"box-1","image":"alpine:latest","osUser":"boxlite","advanced":{"capabilities":{}}}`, - }, - { - name: "recover null capabilities field", - jobType: apiclient.JOBTYPE_RECOVER_BOX, - payload: `{"osUser":"boxlite","errorReason":"retry","advanced":{"capabilities":null}}`, - }, - { - name: "create alternate-case advanced field", - jobType: apiclient.JOBTYPE_CREATE_BOX, - payload: `{"id":"box-1","image":"alpine:latest","osUser":"boxlite","Advanced":{"capabilities":{"add":["SYS_ADMIN"]}}}`, - }, - { - name: "recover old flat policy field", - jobType: apiclient.JOBTYPE_RECOVER_BOX, - payload: `{"osUser":"boxlite","errorReason":"retry","CAPDROP":["NET_RAW"]}`, - }, - { - name: "create snake case flat policy field", - jobType: apiclient.JOBTYPE_CREATE_BOX, - payload: `{"id":"box-1","image":"alpine:latest","osUser":"boxlite","cap_add":["SYS_ADMIN"]}`, - }, - { - name: "recover snake case flat policy field", - jobType: apiclient.JOBTYPE_RECOVER_BOX, - payload: `{"osUser":"boxlite","errorReason":"retry","cap_drop":["NET_RAW"]}`, - }, - } - - for _, test := range tests { - t.Run(test.name, func(t *testing.T) { - capture := &capabilityCaptureBackend{} - executor := &Executor{backend: capture} - job := apiclient.NewJob( - "job-1", - test.jobType, - apiclient.JOBSTATUS_PENDING, - "box", - "box-1", - "2026-01-01T00:00:00Z", - ) - job.Payload = &test.payload - - _, err := executor.executeJob(context.Background(), job) - if err == nil || !strings.Contains(err.Error(), "capability") { - t.Fatalf("expected explicit capability contract error, got %v", err) - } - if capture.createRequest != nil || capture.recoverRequest != nil { - t.Fatal("legacy capability job reached backend") - } - }) - } -} - func (b *capabilityCaptureBackend) Create(_ context.Context, request dto.CreateBoxDTO) (string, string, error) { b.createRequest = &request return "box-1", "boxlite", nil @@ -113,39 +29,37 @@ func (b *capabilityCaptureBackend) RecoverBox(_ context.Context, _ string, reque return nil } -func TestExecuteCapabilityJobsPreservesPolicy(t *testing.T) { +// A job payload travels through the queue as opaque JSON, so this covers the +// hop where a dropped policy would silently restore default privileges. +func TestExecuteJobPreservesCapabilityPolicy(t *testing.T) { tests := []struct { name string jobType apiclient.JobType payload string - capability func(*capabilityCaptureBackend) ([]string, []string) + capability func(*testing.T, *capabilityCaptureBackend) *dto.AdvancedBoxOptionsDTO }{ { name: "create", - jobType: apiclient.JOBTYPE_CREATE_BOX_WITH_CAPABILITIES_V2, - payload: `{"id":"box-1","image":"alpine:latest","osUser":"boxlite","cpuQuota":1,"gpuQuota":0,"memoryQuota":1,"storageQuota":1,"advanced":{"capabilities":{"add":["SYS_ADMIN"],"drop":["NET_RAW"]}}}`, - capability: func(capture *capabilityCaptureBackend) ([]string, []string) { + jobType: apiclient.JOBTYPE_CREATE_BOX, + payload: `{"id":"box-1","image":"alpine:latest","osUser":"boxlite","advanced":{"capabilities":{"add":["SYS_ADMIN"],"drop":["NET_RAW"]}}}`, + capability: func(t *testing.T, capture *capabilityCaptureBackend) *dto.AdvancedBoxOptionsDTO { + t.Helper() if capture.createRequest == nil { t.Fatal("create backend was not called") } - if capture.createRequest.Advanced == nil || capture.createRequest.Advanced.Capabilities == nil { - t.Fatal("create backend did not receive advanced capabilities") - } - return capture.createRequest.Advanced.Capabilities.Add, capture.createRequest.Advanced.Capabilities.Drop + return capture.createRequest.Advanced }, }, { name: "recover", - jobType: apiclient.JOBTYPE_RECOVER_BOX_WITH_CAPABILITIES_V2, - payload: `{"osUser":"boxlite","cpuQuota":1,"gpuQuota":0,"memoryQuota":1,"storageQuota":1,"errorReason":"retry","advanced":{"capabilities":{"add":["SYS_ADMIN"],"drop":["NET_RAW"]}}}`, - capability: func(capture *capabilityCaptureBackend) ([]string, []string) { + jobType: apiclient.JOBTYPE_RECOVER_BOX, + payload: `{"osUser":"boxlite","errorReason":"retry","advanced":{"capabilities":{"add":["SYS_ADMIN"],"drop":["NET_RAW"]}}}`, + capability: func(t *testing.T, capture *capabilityCaptureBackend) *dto.AdvancedBoxOptionsDTO { + t.Helper() if capture.recoverRequest == nil { t.Fatal("recover backend was not called") } - if capture.recoverRequest.Advanced == nil || capture.recoverRequest.Advanced.Capabilities == nil { - t.Fatal("recover backend did not receive advanced capabilities") - } - return capture.recoverRequest.Advanced.Capabilities.Add, capture.recoverRequest.Advanced.Capabilities.Drop + return capture.recoverRequest.Advanced }, }, } @@ -165,163 +79,18 @@ func TestExecuteCapabilityJobsPreservesPolicy(t *testing.T) { job.Payload = &test.payload if _, err := executor.executeJob(context.Background(), job); err != nil { - t.Fatalf("execute capability job: %v", err) + t.Fatalf("execute job: %v", err) } - capabilityAdd, capabilityDrop := test.capability(capture) - if !reflect.DeepEqual(capabilityAdd, []string{"SYS_ADMIN"}) { - t.Fatalf("unexpected advanced.capabilities.add: %v", capabilityAdd) + advanced := test.capability(t, capture) + if advanced == nil || advanced.Capabilities == nil { + t.Fatal("backend did not receive advanced capabilities") } - if !reflect.DeepEqual(capabilityDrop, []string{"NET_RAW"}) { - t.Fatalf("unexpected advanced.capabilities.drop: %v", capabilityDrop) - } - }) - } -} - -func TestCapabilityJobsValidateRequiredFieldsBeforeBackend(t *testing.T) { - tests := []struct { - name string - jobType apiclient.JobType - payload string - }{ - { - name: "create missing id", - jobType: apiclient.JOBTYPE_CREATE_BOX_WITH_CAPABILITIES_V2, - payload: `{"image":"alpine:latest","osUser":"boxlite","cpuQuota":1,"gpuQuota":0,"memoryQuota":1,"storageQuota":1,"advanced":{"capabilities":{"add":["SYS_ADMIN"]}}}`, - }, - { - name: "create invalid quotas", - jobType: apiclient.JOBTYPE_CREATE_BOX_WITH_CAPABILITIES_V2, - payload: `{"id":"box-1","image":"alpine:latest","osUser":"boxlite","cpuQuota":0,"gpuQuota":0,"memoryQuota":0,"storageQuota":0,"advanced":{"capabilities":{"add":["SYS_ADMIN"]}}}`, - }, - { - name: "recover missing error reason", - jobType: apiclient.JOBTYPE_RECOVER_BOX_WITH_CAPABILITIES_V2, - payload: `{"osUser":"boxlite","cpuQuota":1,"gpuQuota":0,"memoryQuota":1,"storageQuota":1,"advanced":{"capabilities":{"drop":["NET_RAW"]}}}`, - }, - } - - for _, test := range tests { - t.Run(test.name, func(t *testing.T) { - capture := &capabilityCaptureBackend{} - executor := &Executor{backend: capture} - job := apiclient.NewJob( - "job-1", - test.jobType, - apiclient.JOBSTATUS_PENDING, - "box", - "box-1", - "2026-01-01T00:00:00Z", - ) - job.Payload = &test.payload - - _, err := executor.executeJob(context.Background(), job) - if err == nil || !strings.Contains(err.Error(), "validate payload") { - t.Fatalf("expected payload validation error, got %v", err) - } - if capture.createRequest != nil || capture.recoverRequest != nil { - t.Fatal("invalid strict capability job reached backend") - } - }) - } -} - -func TestCapabilityJobsRejectUnknownNestedFieldsBeforeBackend(t *testing.T) { - tests := []struct { - name string - jobType apiclient.JobType - payload string - }{ - { - name: "create top-level", - jobType: apiclient.JOBTYPE_CREATE_BOX_WITH_CAPABILITIES_V2, - payload: `{"id":"box-1","image":"alpine:latest","osUser":"boxlite","advanced":{"capabilities":{"add":["SYS_ADMIN"]}},"futureSecurityOption":true}`, - }, - { - name: "create advanced", - jobType: apiclient.JOBTYPE_CREATE_BOX_WITH_CAPABILITIES_V2, - payload: `{"id":"box-1","image":"alpine:latest","osUser":"boxlite","advanced":{"capabilities":{"add":["SYS_ADMIN"]},"futureSecurityOption":true}}`, - }, - { - name: "recover capabilities", - jobType: apiclient.JOBTYPE_RECOVER_BOX_WITH_CAPABILITIES_V2, - payload: `{"osUser":"boxlite","errorReason":"retry","advanced":{"capabilities":{"drop":["NET_RAW"],"futureCapabilityOption":true}}}`, - }, - } - - for _, test := range tests { - t.Run(test.name, func(t *testing.T) { - capture := &capabilityCaptureBackend{} - executor := &Executor{backend: capture} - job := apiclient.NewJob( - "job-1", - test.jobType, - apiclient.JOBSTATUS_PENDING, - "box", - "box-1", - "2026-01-01T00:00:00Z", - ) - job.Payload = &test.payload - - _, err := executor.executeJob(context.Background(), job) - if err == nil || !strings.Contains(err.Error(), "unknown field") { - t.Fatalf("expected recursive unknown-field rejection, got %v", err) - } - if capture.createRequest != nil || capture.recoverRequest != nil { - t.Fatal("invalid strict capability job reached backend") - } - }) - } -} - -func TestCapabilityJobsRejectNullNestedFieldsBeforeBackend(t *testing.T) { - tests := []struct { - name string - jobType apiclient.JobType - payload string - }{ - { - name: "create null advanced", - jobType: apiclient.JOBTYPE_CREATE_BOX_WITH_CAPABILITIES_V2, - payload: `{"id":"box-1","image":"alpine:latest","osUser":"boxlite","advanced":null}`, - }, - { - name: "recover null capabilities", - jobType: apiclient.JOBTYPE_RECOVER_BOX_WITH_CAPABILITIES_V2, - payload: `{"osUser":"boxlite","errorReason":"retry","advanced":{"capabilities":null}}`, - }, - { - name: "create null add", - jobType: apiclient.JOBTYPE_CREATE_BOX_WITH_CAPABILITIES_V2, - payload: `{"id":"box-1","image":"alpine:latest","osUser":"boxlite","advanced":{"capabilities":{"add":null,"drop":["NET_RAW"]}}}`, - }, - { - name: "recover null drop", - jobType: apiclient.JOBTYPE_RECOVER_BOX_WITH_CAPABILITIES_V2, - payload: `{"osUser":"boxlite","errorReason":"retry","advanced":{"capabilities":{"add":["SYS_ADMIN"],"drop":null}}}`, - }, - } - - for _, test := range tests { - t.Run(test.name, func(t *testing.T) { - capture := &capabilityCaptureBackend{} - executor := &Executor{backend: capture} - job := apiclient.NewJob( - "job-1", - test.jobType, - apiclient.JOBSTATUS_PENDING, - "box", - "box-1", - "2026-01-01T00:00:00Z", - ) - job.Payload = &test.payload - - if _, err := executor.executeJob(context.Background(), job); err == nil { - t.Fatal("expected null-field rejection") + if !reflect.DeepEqual(advanced.Capabilities.Add, []string{"SYS_ADMIN"}) { + t.Fatalf("unexpected advanced.capabilities.add: %v", advanced.Capabilities.Add) } - if capture.createRequest != nil || capture.recoverRequest != nil { - t.Fatal("null-bearing strict capability job reached backend") + if !reflect.DeepEqual(advanced.Capabilities.Drop, []string{"NET_RAW"}) { + t.Fatalf("unexpected advanced.capabilities.drop: %v", advanced.Capabilities.Drop) } }) } diff --git a/apps/runner/pkg/runner/v2/executor/executor.go b/apps/runner/pkg/runner/v2/executor/executor.go index 418131e30..b59aaae55 100644 --- a/apps/runner/pkg/runner/v2/executor/executor.go +++ b/apps/runner/pkg/runner/v2/executor/executor.go @@ -131,8 +131,6 @@ func (e *Executor) executeJob(ctx context.Context, job *apiclient.Job) (any, err switch job.GetType() { case apiclient.JOBTYPE_CREATE_BOX: resultMetadata, err = e.createBox(ctx, job) - case apiclient.JOBTYPE_CREATE_BOX_WITH_CAPABILITIES_V2: - resultMetadata, err = e.createBoxWithCapabilities(ctx, job) case apiclient.JOBTYPE_START_BOX: resultMetadata, err = e.startBox(ctx, job) case apiclient.JOBTYPE_STOP_BOX: @@ -145,8 +143,6 @@ func (e *Executor) executeJob(ctx context.Context, job *apiclient.Job) (any, err resultMetadata, err = e.updateNetworkSettings(ctx, job) case apiclient.JOBTYPE_RECOVER_BOX: resultMetadata, err = e.recoverBox(ctx, job) - case apiclient.JOBTYPE_RECOVER_BOX_WITH_CAPABILITIES_V2: - resultMetadata, err = e.recoverBoxWithCapabilities(ctx, job) default: err = fmt.Errorf("unknown job type: %s", job.GetType()) } diff --git a/docs/architecture/container-capabilities.md b/docs/architecture/container-capabilities.md index 6407529c0..5f6e37dbc 100644 --- a/docs/architecture/container-capabilities.md +++ b/docs/architecture/container-capabilities.md @@ -51,44 +51,35 @@ the resolved type and cannot reinterpret the policy. ## Compatibility and rollout -Capability policy is negotiated at every versioned boundary. A remote SDK -rechecks `linux_capabilities_enabled` from `GET /v1/config` immediately before -creating a box with a custom policy, then posts to the strict create route that -schema-unaware API builds do not expose. This closes both stale discovery-cache -and mixed-version load-balancer fail-open paths. A BoxLite host requires the guest's -`linux-capabilities-v2` feature before sending the nested policy. The cloud control -plane likewise schedules capability-bearing boxes only onto runners -advertising the feature, and the start/restart action checks uncached persisted -runner state again immediately before invoking the runner. Missing -advertisements therefore fail closed; the second runner check also narrows the -selection-to-dispatch race. - -The structured host/guest protobuf carries the policy under an `advanced` -message. The `-v2` feature token and capability-specific v2 job kinds keep -mixed-version guests and queued jobs from silently ignoring the nested -contract. - -Persistence has explicit downgrade barriers. Opening a local database migrates -its schema to v9, so a v8 binary refuses to reopen it instead of discarding -persisted capability fields. Ordinary exports remain archive v3 for backward -compatibility; an export carrying `advanced.capabilities` is archive v4, which -older importers reject. Imported archive options are validated before any disk -is installed or box metadata is persisted. - -An older cloud API cannot understand fields that did not exist in its schema. -For a mixed-version deployment, roll out the database migration and control -plane first, then capable runners and guests, and expose capability-aware -clients only after that path is healthy. The new control plane safely rejects -custom policies while only old runners are available. Ordinary create, get, -and list operations from older clients remain compatible throughout the -rollout. Named `get_or_create` deliberately requires the strict server-side -operation even for an empty requested policy: otherwise an old API could omit a -persisted custom policy and make reuse appear safe. Drain a runner before downgrading or -rolling it back: a previously positive advertisement cannot prove that the -runner binary has not changed since its last heartbeat. Once a custom-policy -box has been accepted, do not roll the control plane back to a build that -predates these fields: such a build cannot preserve them while recreating or -recovering a box. Roll forward to a capability-aware build instead. +Every versioned boundary negotiates capability support before a policy can be +silently dropped: + +- A remote SDK re-reads `linux_capabilities_enabled` from `GET /v1/config` + (uncached) immediately before creating a box with a custom policy, so a + server rollback cannot be masked by a stale discovery cache. +- A BoxLite host requires the guest's `linux-capabilities-v2` Ping feature + before sending the nested policy. +- The cloud control plane schedules capability-bearing boxes only onto runners + advertising the same feature, and re-checks it before start and recovery. + +A missing advertisement therefore fails closed. Boundaries that do not carry a +custom policy are unaffected: ordinary create, get, and list keep working +against any server version. Inspection does not report the policy — it is +create-time configuration, not box state. + +Named `get_or_create` on the local runtime refuses to adopt an existing box +whose capability policy differs from the requested one, so reuse cannot +silently widen or narrow privileges. + +An export carrying a capability policy is stamped archive v4; ordinary +exports stay v3. A pre-capability importer accepts only up to v3, so it +refuses the archive instead of dropping the policy and starting the box with +wider privileges than the archive asked for. + +Once a custom-policy box exists, do not roll the control plane back to a build +that predates these fields: such a build cannot preserve them while recreating +or recovering a box. Roll forward instead, and drain a runner before +downgrading it — a past advertisement cannot prove the binary is unchanged. ## Project research @@ -138,8 +129,8 @@ from its OCI realization. - **Flat top-level add/drop fields:** common in Docker-compatible engine APIs, but BoxLite's top-level options also cover application lifecycle and resource settings. Grouping the expert-only privilege policy under `advanced` keeps - creation and inspection extensible and matches Kubernetes, ECS, ACI, and - Terraform's structured security models. + creation extensible and matches Kubernetes, ECS, ACI, and Terraform's + structured security models. - **Host semantic validation:** the host and guest may carry different OCI libraries or kernels. Freezing the supported list in Rust, TypeScript, and every SDK creates version skew; only lexical validation belongs upstream. diff --git a/make/test.mk b/make/test.mk index 0cc58d36c..8ae5b3104 100644 --- a/make/test.mk +++ b/make/test.mk @@ -1,5 +1,4 @@ -PHONY_TARGETS += test -PHONY_TARGETS += test\:unit\:guest-capabilities +PHONY_TARGETS += test test\:unit\:guest # Mirrors GitHub Actions strategy.fail-fast. Default false: aggregator # targets run every sub-suite even if an earlier one fails, then exit @@ -207,19 +206,20 @@ test\:unit\:rust: fi; \ exit $$rc -# Guest capability policy is Linux-only and does not require a VM. Keep this -# focused target separate so macOS contributors can run the normal unit suite, -# while Linux CI executes the policy and OCI construction tests themselves. -test\:unit\:guest-capabilities: +# Guest crate unit tests. Linux-only (the crate does not build elsewhere) and +# excluded from test:unit:rust because the zygote suite forks real processes. +# Runs the pure-logic modules, which is where capability resolution and OCI +# spec construction live — otherwise nothing exercises them. +test\:unit\:guest: @if [ "$$(uname)" != "Linux" ]; then \ - echo "⏭️ Guest capability unit tests require Linux"; \ + echo "⏭️ Guest unit tests require Linux"; \ exit 0; \ fi; \ - echo "🧪 Running guest capability unit tests..."; \ + echo "🧪 Running guest unit tests..."; \ if command -v cargo-nextest >/dev/null 2>&1; then \ - cargo nextest run --no-tests=fail -p boxlite-guest -E 'test(~capabilit)'; \ + cargo nextest run --no-tests=fail -p boxlite-guest -E 'test(~capabilit) + test(~spec::tests)'; \ else \ - cargo test -p boxlite-guest capabilit -- --test-threads=1; \ + cargo test -p boxlite-guest --bins -- --test-threads=1 capabilit spec::tests; \ fi # Pre-warm Rust integration test image cache (internal helper, still callable). diff --git a/openapi/box.openapi.yaml b/openapi/box.openapi.yaml index 11ae95107..34737e5b1 100644 --- a/openapi/box.openapi.yaml +++ b/openapi/box.openapi.yaml @@ -245,78 +245,6 @@ paths: schema: $ref: "#/components/schemas/ListBoxesResponse" - /{prefix}/boxes/strict: - parameters: - - $ref: "#/components/parameters/prefix" - - post: - operationId: createBoxStrict - summary: Create a box with fail-closed option handling - description: | - Creates a box through a route introduced with strict option handling. - Clients MUST use this route when sending `advanced.capabilities` so a - schema-unaware server returns 404 instead of silently discarding those - security fields at the legacy create endpoint. - tags: [Boxes] - parameters: - - $ref: "#/components/parameters/idempotencyKey" - requestBody: - required: true - content: - application/json: - schema: - $ref: "#/components/schemas/StrictCreateBoxRequest" - responses: - "201": - description: Box created - headers: - Location: - description: URL of the created box - schema: - type: string - content: - application/json: - schema: - $ref: "#/components/schemas/Box" - "400": - $ref: "#/components/responses/BadRequestError" - "409": - $ref: "#/components/responses/ConflictError" - "422": - $ref: "#/components/responses/UnprocessableEntityError" - - /{prefix}/boxes/get-or-create/strict: - parameters: - - $ref: "#/components/parameters/prefix" - - post: - operationId: getOrCreateBoxStrict - summary: Get a named box or create it with backend compatibility checks - description: | - Atomically adopts an existing named box or creates it. The server - validates the requested options against an existing box before reuse; - clients delegate compatibility decisions to the backend. - tags: [Boxes] - requestBody: - required: true - content: - application/json: - schema: - $ref: "#/components/schemas/StrictCreateBoxRequest" - responses: - "200": - description: Existing or newly created box - content: - application/json: - schema: - $ref: "#/components/schemas/GetOrCreateBoxResponse" - "400": - $ref: "#/components/responses/BadRequestError" - "409": - $ref: "#/components/responses/ConflictError" - "422": - $ref: "#/components/responses/UnprocessableEntityError" - /{prefix}/boxes/{box_id}: parameters: - $ref: "#/components/parameters/prefix" @@ -1632,16 +1560,6 @@ components: default: true description: Whether the box automatically resumes when accessed after AutoPause - GetOrCreateBoxResponse: - type: object - required: [box_info, created] - properties: - box_info: - $ref: "#/components/schemas/Box" - created: - type: boolean - description: Whether this request created a new box - BoxStatus: type: string description: | @@ -1661,18 +1579,13 @@ components: - unknown CreateBoxRequest: - allOf: - - $ref: "#/components/schemas/CreateBoxRequestBase" - - type: object - description: Legacy create request accepted by `POST /boxes`. - properties: - security: - $ref: "#/components/schemas/SecurityPreset" - - CreateBoxRequestBase: type: object - description: Shared non-security configuration for creating a new box. + description: Configuration for creating a new box. properties: + advanced: + $ref: "#/components/schemas/CreateBoxAdvancedOptions" + security: + $ref: "#/components/schemas/SecurityPreset" name: type: string description: Unique name within the workspace @@ -1766,22 +1679,9 @@ components: default: true description: Whether the box automatically resumes when accessed after AutoPause - StrictCreateBoxRequest: - allOf: - - $ref: "#/components/schemas/CreateBoxRequestBase" - - type: object - description: | - Fail-closed create request for security-sensitive options. This - schema is accepted by the strict create and strict get-or-create - routes. - properties: - advanced: - $ref: "#/components/schemas/CreateBoxAdvancedOptions" - unevaluatedProperties: false - CreateBoxAdvancedOptions: type: object - description: Expert-only container options accepted by strict box creation routes. + description: Expert-only container options. additionalProperties: false properties: capabilities: diff --git a/openapi/reference-server/server.py b/openapi/reference-server/server.py index a7e9eba74..01b45bb85 100644 --- a/openapi/reference-server/server.py +++ b/openapi/reference-server/server.py @@ -154,7 +154,7 @@ class CreateBoxAdvancedOptions(BaseModel): capabilities: ContainerCapabilities = Field(default_factory=ContainerCapabilities) -class CreateBoxRequestBase(BaseModel): +class CreateBoxRequest(BaseModel): model_config = ConfigDict(extra="forbid") name: Optional[str] = None @@ -176,23 +176,8 @@ class CreateBoxRequestBase(BaseModel): auto_delete: Optional[int] = Field(default=None, ge=0) auto_resume: Optional[bool] = None detach: Optional[bool] = False - - -class CreateBoxRequest(CreateBoxRequestBase): - # Legacy reference-server compatibility only. The strict capability route - # intentionally excludes client-controlled sandbox policy. - security: Optional[str] = None - - -class StrictCreateBoxRequest(CreateBoxRequestBase): advanced: Optional[CreateBoxAdvancedOptions] = None - - @field_validator("advanced", mode="before") - @classmethod - def reject_null_advanced(cls, advanced): - if advanced is None: - raise ValueError("advanced must be an object when provided") - return advanced + security: Optional[str] = None class StopBoxRequest(BaseModel): @@ -393,7 +378,7 @@ def box_info_to_dict(info) -> dict: } -def build_box_options(req: CreateBoxRequestBase) -> boxlite.BoxOptions: +def build_box_options(req: CreateBoxRequest) -> boxlite.BoxOptions: kwargs = {} if req.image and not req.rootfs_path: kwargs["image"] = req.image @@ -420,14 +405,13 @@ def build_box_options(req: CreateBoxRequestBase) -> boxlite.BoxOptions: kwargs["cmd"] = req.cmd if req.user is not None: kwargs["user"] = req.user - advanced = getattr(req, "advanced", None) - if advanced is not None and ( - advanced.capabilities.add or advanced.capabilities.drop + if req.advanced is not None and ( + req.advanced.capabilities.add or req.advanced.capabilities.drop ): kwargs["advanced"] = boxlite.AdvancedBoxOptions( capabilities=boxlite.ContainerCapabilities( - add=advanced.capabilities.add, - drop=advanced.capabilities.drop, + add=req.advanced.capabilities.add, + drop=req.advanced.capabilities.drop, ) ) if req.secrets: @@ -458,15 +442,14 @@ def build_box_options(req: CreateBoxRequestBase) -> boxlite.BoxOptions: (p.get("host_port", 0), p["guest_port"], p.get("protocol", "tcp")) for p in req.ports ] - security = getattr(req, "security", None) - if security: + if req.security: presets = { "development": boxlite.SecurityOptions.development, "standard": boxlite.SecurityOptions.standard, "maximum": boxlite.SecurityOptions.maximum, } - if security in presets: - kwargs["security"] = presets[security]() + if req.security in presets: + kwargs["security"] = presets[req.security]() return boxlite.BoxOptions(**kwargs) @@ -641,19 +624,6 @@ async def create_box( req: CreateBoxRequest, _auth: dict = Depends(require_auth), ): - return await create_box_with_options(prefix, req) - - -@app.post("/v1/{prefix}/boxes/strict", status_code=201) -async def create_box_strict( - prefix: str, - req: StrictCreateBoxRequest, - _auth: dict = Depends(require_auth), -): - return await create_box_with_options(prefix, req) - - -async def create_box_with_options(prefix: str, req: CreateBoxRequestBase): options = build_box_options(req) box_handle = await state.runtime.create(options, req.name) await cache_box_handle(box_handle) diff --git a/openapi/reference-server/tests/test_handle_cache.py b/openapi/reference-server/tests/test_handle_cache.py index b1b6a21b9..6c16a72a3 100644 --- a/openapi/reference-server/tests/test_handle_cache.py +++ b/openapi/reference-server/tests/test_handle_cache.py @@ -158,7 +158,7 @@ async def test_create_box_caches_handle(self) -> None: self.assertIn("box-create", SERVER.state.active_boxes_by_id) def test_build_box_options_forwards_capability_policy(self) -> None: - request = SERVER.StrictCreateBoxRequest( + request = SERVER.CreateBoxRequest( advanced=SERVER.CreateBoxAdvancedOptions( capabilities=SERVER.ContainerCapabilities( add=["SYS_ADMIN"], @@ -202,19 +202,12 @@ def test_build_box_options_forwards_capability_policy(self) -> None: def test_create_box_rejects_malformed_capability_policy(self) -> None: for capability in ("NET-ADMIN", "123", "ß"): with self.assertRaises(ValueError): - SERVER.StrictCreateBoxRequest( + SERVER.CreateBoxRequest( advanced=SERVER.CreateBoxAdvancedOptions( capabilities=SERVER.ContainerCapabilities(add=[capability]) ) ) - def test_strict_create_does_not_expose_client_security_policy(self) -> None: - with self.assertRaises(ValueError): - SERVER.StrictCreateBoxRequest(security="development") - - schema = SERVER.StrictCreateBoxRequest.model_json_schema() - self.assertNotIn("security", schema["properties"]) - async def test_clone_box_caches_cloned_handle(self) -> None: source = _make_box_handle("box-source") cloned = _make_box_handle("box-cloned") diff --git a/sdks/c/include/boxlite.h b/sdks/c/include/boxlite.h index 80104126c..c9e7def80 100644 --- a/sdks/c/include/boxlite.h +++ b/sdks/c/include/boxlite.h @@ -615,57 +615,49 @@ enum BoxliteErrorCode boxlite_runtime_metrics(CBoxliteRuntime *runtime, void *user_data, CBoxliteError *out_error); -/** - * Borrow the box's network capability into a new owned handle. - * - * On success, `*out_network` must be released with `boxlite_network_free`. - * Returns `InvalidArgument` for null input/output pointers and writes details - * to `out_error` when provided. - */ +// Borrow the box's network capability into a new owned handle. +// +// On success, `*out_network` must be released with `boxlite_network_free`. +// Returns `InvalidArgument` for null input/output pointers and writes details +// to `out_error` when provided. enum BoxliteErrorCode boxlite_box_network(CBoxHandle *handle, CBoxNetworkHandle **out_network, CBoxliteError *out_error); -/** Release a network handle. Accepts NULL and does not affect the box handle. */ +// Release a network handle. Accepts NULL and does not affect the box handle. void boxlite_network_free(CBoxNetworkHandle *network); -/** - * Prepare a one-shot tunnel to `port` in the box. - * - * On success, `*out_tunnel` owns a handle that must be released with - * `boxlite_tunnel_free`. Returns `InvalidArgument` for a null network/output - * pointer or port zero, with details written to `out_error` when provided. - */ +// Prepare a one-shot tunnel to `port` in the box. +// +// On success, `*out_tunnel` owns a handle that must be released with +// `boxlite_tunnel_free`. Returns `InvalidArgument` for a null network/output +// pointer or port zero, with details written to `out_error` when provided. enum BoxliteErrorCode boxlite_network_tunnel(CBoxNetworkHandle *network, uint16_t port, CBoxTunnelHandle **out_tunnel, CBoxliteError *out_error); -/** Release a tunnel handle and any unconsumed connection. Accepts NULL. */ +// Release a tunnel handle and any unconsumed connection. Accepts NULL. void boxlite_tunnel_free(CBoxTunnelHandle *tunnel); -/** - * Inspect a prepared tunnel without transferring ownership. - * - * `out_type` selects the valid output: URI returns an allocated `*out_uri` - * that the caller must release with `boxlite_free_string`; FileDescriptor - * returns a borrowed `*out_fd` valid only while the tunnel remains alive. - * Unused outputs are initialized to NULL and -1. Errors are returned as a - * `BoxliteErrorCode` and described through `out_error` when provided. - */ +// Inspect a prepared tunnel without transferring ownership. +// +// `out_type` selects the valid output: URI returns an allocated `*out_uri` +// that the caller must release with `boxlite_free_string`; FileDescriptor +// returns a borrowed `*out_fd` valid only while the tunnel remains alive. +// Unused outputs are initialized to NULL and -1. Errors are returned as a +// `BoxliteErrorCode` and described through `out_error` when provided. enum BoxliteErrorCode boxlite_tunnel_endpoint(CBoxTunnelHandle *tunnel, enum BoxliteEndpointType *out_type, char **out_uri, int32_t *out_fd, CBoxliteError *out_error); -/** - * Consume a tunnel's single connection and return its owned file descriptor. - * - * On success, the caller owns `*out_fd` and must close it. A second call - * returns `InvalidState`. On failure `*out_fd` remains -1 and `out_error` - * receives details when provided. - */ +// Consume a tunnel's single connection and return its owned file descriptor. +// +// On success, the caller owns `*out_fd` and must close it. A second call +// returns `InvalidState`. On failure `*out_fd` remains -1 and `out_error` +// receives details when provided. enum BoxliteErrorCode boxlite_tunnel_connect(CBoxTunnelHandle *tunnel, int32_t *out_fd, CBoxliteError *out_error); @@ -720,7 +712,7 @@ void boxlite_options_add_secret(CBoxliteOptions *opts, const char *const *hosts, int hosts_count); -// Deprecated: use boxlite_options_set_auto_delete_interval. +// Deprecated: use `boxlite_options_set_auto_delete_interval`. void boxlite_options_set_auto_remove(CBoxliteOptions *opts, int val); void boxlite_options_set_auto_pause_interval(CBoxliteOptions *opts, uint32_t seconds); diff --git a/sdks/c/src/advanced_options.rs b/sdks/c/src/advanced_options.rs index 28a3020ae..3e93686da 100644 --- a/sdks/c/src/advanced_options.rs +++ b/sdks/c/src/advanced_options.rs @@ -128,7 +128,7 @@ fn set_capability_list( } Err(()) => { // Keep the handle invalid if a caller ignores the return code. The - // subsequent BoxOptions::validate call then rejects the policy + // subsequent BoxOptions::sanitize call then rejects the policy // instead of silently falling back to the baseline. assign( &mut handle.options, diff --git a/sdks/c/src/network.rs b/sdks/c/src/network.rs index 94902bf91..9f4b71740 100644 --- a/sdks/c/src/network.rs +++ b/sdks/c/src/network.rs @@ -50,6 +50,11 @@ pub enum BoxliteEndpointType { BoxliteEndpointTypeFileDescriptor = 1, } +/// Borrow the box's network capability into a new owned handle. +/// +/// On success, `*out_network` must be released with `boxlite_network_free`. +/// Returns `InvalidArgument` for null input/output pointers and writes details +/// to `out_error` when provided. #[unsafe(no_mangle)] pub unsafe extern "C" fn boxlite_box_network( handle: *mut CBoxHandle, @@ -76,6 +81,7 @@ pub unsafe extern "C" fn boxlite_box_network( } } +/// Release a network handle. Accepts NULL and does not affect the box handle. #[unsafe(no_mangle)] pub unsafe extern "C" fn boxlite_network_free(network: *mut CBoxNetworkHandle) { if !network.is_null() { @@ -83,6 +89,11 @@ pub unsafe extern "C" fn boxlite_network_free(network: *mut CBoxNetworkHandle) { } } +/// Prepare a one-shot tunnel to `port` in the box. +/// +/// On success, `*out_tunnel` owns a handle that must be released with +/// `boxlite_tunnel_free`. Returns `InvalidArgument` for a null network/output +/// pointer or port zero, with details written to `out_error` when provided. #[unsafe(no_mangle)] pub unsafe extern "C" fn boxlite_network_tunnel( network: *mut CBoxNetworkHandle, @@ -141,6 +152,7 @@ pub unsafe extern "C" fn boxlite_network_tunnel( } } +/// Release a tunnel handle and any unconsumed connection. Accepts NULL. #[unsafe(no_mangle)] pub unsafe extern "C" fn boxlite_tunnel_free(tunnel: *mut CBoxTunnelHandle) { if !tunnel.is_null() { @@ -148,6 +160,13 @@ pub unsafe extern "C" fn boxlite_tunnel_free(tunnel: *mut CBoxTunnelHandle) { } } +/// Inspect a prepared tunnel without transferring ownership. +/// +/// `out_type` selects the valid output: URI returns an allocated `*out_uri` +/// that the caller must release with `boxlite_free_string`; FileDescriptor +/// returns a borrowed `*out_fd` valid only while the tunnel remains alive. +/// Unused outputs are initialized to NULL and -1. Errors are returned as a +/// `BoxliteErrorCode` and described through `out_error` when provided. #[unsafe(no_mangle)] pub unsafe extern "C" fn boxlite_tunnel_endpoint( tunnel: *mut CBoxTunnelHandle, @@ -216,6 +235,11 @@ pub unsafe extern "C" fn boxlite_tunnel_endpoint( } } +/// Consume a tunnel's single connection and return its owned file descriptor. +/// +/// On success, the caller owns `*out_fd` and must close it. A second call +/// returns `InvalidState`. On failure `*out_fd` remains -1 and `out_error` +/// receives details when provided. #[unsafe(no_mangle)] pub unsafe extern "C" fn boxlite_tunnel_connect( tunnel: *mut CBoxTunnelHandle, diff --git a/sdks/c/src/tests.rs b/sdks/c/src/tests.rs index b5e6d6748..63946def0 100644 --- a/sdks/c/src/tests.rs +++ b/sdks/c/src/tests.rs @@ -512,7 +512,7 @@ fn null_capability_element_cannot_weaken_policy() { boxlite_options_set_advanced(opts, advanced); (*opts) .options - .validate() + .sanitize() .expect_err("a null cap_drop element must fail closed"); boxlite_advanced_options_free(advanced); boxlite_options_free(opts); @@ -544,7 +544,7 @@ fn invalid_utf8_capability_cannot_weaken_policy() { boxlite_options_set_advanced(opts, advanced); (*opts) .options - .validate() + .sanitize() .expect_err("invalid UTF-8 in cap_add must fail closed"); boxlite_advanced_options_free(advanced); boxlite_options_free(opts); diff --git a/sdks/go/runtime.go b/sdks/go/runtime.go index 6503f03d5..fd7b04f2b 100644 --- a/sdks/go/runtime.go +++ b/sdks/go/runtime.go @@ -191,8 +191,9 @@ func (r *Runtime) Create(ctx context.Context, image string, opts ...BoxOption) ( // The second return value, created, is true when a new box was created and // false when an existing box was adopted — letting callers skip one-time // initialization for an adopted box. -// General options are ignored when adopting an existing box, but -// AdvancedBoxOptions capabilities must match its persisted security policy. +// General options are ignored when adopting an existing box. The local +// runtime additionally refuses to adopt one whose capability policy differs +// from the requested one. // // On context cancellation it only frees the returned handle (like Get); it // never force-removes the box, because an adopted box may be one the caller did diff --git a/sdks/node/src/runtime.rs b/sdks/node/src/runtime.rs index 644eb841a..337156756 100644 --- a/sdks/node/src/runtime.rs +++ b/sdks/node/src/runtime.rs @@ -145,8 +145,9 @@ impl JsBoxlite { /// Returns an object with `box` (the box handle) and `created` (true if /// newly created, false if an existing box was found). /// - /// When an existing box is returned, general options are ignored, but - /// `advanced.capabilities` must match its persisted security policy. + /// When an existing box is returned, general options are ignored. The + /// local runtime additionally refuses to adopt a box whose capability + /// policy differs from the requested one. /// /// # Arguments /// * `options` - Box configuration (used only if creating a new box) diff --git a/sdks/python/boxlite/sync_api/_boxlite.py b/sdks/python/boxlite/sync_api/_boxlite.py index 12aed1233..3b7ab626a 100644 --- a/sdks/python/boxlite/sync_api/_boxlite.py +++ b/sdks/python/boxlite/sync_api/_boxlite.py @@ -264,8 +264,9 @@ def create( Args: options: BoxOptions specifying image, resources, etc. General - options are ignored when a box is reused, but - advanced.capabilities must match its persisted security policy. + options are ignored when a box is reused; the local runtime + additionally refuses to reuse a box whose capability policy + differs from the requested one. name: Optional unique name for the box. Returns: diff --git a/src/boxlite/src/db/migration/mod.rs b/src/boxlite/src/db/migration/mod.rs index 2e3ba0236..60609c5f0 100644 --- a/src/boxlite/src/db/migration/mod.rs +++ b/src/boxlite/src/db/migration/mod.rs @@ -10,7 +10,6 @@ mod v4_to_v5; mod v5_to_v6; mod v6_to_v7; mod v7_to_v8; -mod v8_to_v9; use std::path::Path; @@ -80,6 +79,5 @@ fn all_migrations() -> Vec> { Box::new(v5_to_v6::ReplaceSnapshots), Box::new(v6_to_v7::MoveDisksAndAddBaseDisk), Box::new(v7_to_v8::RenameNetworkSpec), - Box::new(v8_to_v9::GuardCapabilityPolicy), ] } diff --git a/src/boxlite/src/db/migration/v8_to_v9.rs b/src/boxlite/src/db/migration/v8_to_v9.rs deleted file mode 100644 index 7cef4e074..000000000 --- a/src/boxlite/src/db/migration/v8_to_v9.rs +++ /dev/null @@ -1,34 +0,0 @@ -//! Migration v8 → v9: establish the capability-policy compatibility boundary. -//! -//! `BoxOptions` is stored as JSON, so no row rewrite is needed. Bumping the -//! schema prevents a v8 binary—which does not understand -//! `advanced.capabilities`—from opening a database after a newer binary may -//! have persisted that policy and silently restoring a weaker policy. - -use std::path::Path; - -use rusqlite::Connection; - -use boxlite_shared::errors::BoxliteResult; - -use super::Migration; - -pub(crate) struct GuardCapabilityPolicy; - -impl Migration for GuardCapabilityPolicy { - fn source_version(&self) -> i32 { - 8 - } - - fn target_version(&self) -> i32 { - 9 - } - - fn description(&self) -> &str { - "Require capability-aware readers for persisted BoxOptions" - } - - fn run(&self, _conn: &Connection, _home_dir: Option<&Path>) -> BoxliteResult<()> { - Ok(()) - } -} diff --git a/src/boxlite/src/db/mod.rs b/src/boxlite/src/db/mod.rs index 26c063184..340fdc0c7 100644 --- a/src/boxlite/src/db/mod.rs +++ b/src/boxlite/src/db/mod.rs @@ -200,35 +200,6 @@ mod tests { assert!(tables.contains(&"snapshot".to_string())); } - #[test] - fn test_db_migration_v8_to_v9() { - let temp_dir = TempDir::new().unwrap(); - let db_path = temp_dir.path().join("test.db"); - - { - let conn = Connection::open(&db_path).unwrap(); - conn.execute_batch(schema::SCHEMA_VERSION_TABLE).unwrap(); - let now = Utc::now().to_rfc3339(); - conn.execute( - "INSERT INTO schema_version (id, version, updated_at) VALUES (1, 8, ?1)", - rusqlite::params![now], - ) - .unwrap(); - } - - let db = Database::open(&db_path).unwrap(); - let version: i32 = db - .conn() - .query_row( - "SELECT version FROM schema_version WHERE id = 1", - [], - |row| row.get(0), - ) - .unwrap(); - - assert_eq!(version, 9); - } - #[test] fn test_db_migration_v4_to_v7() { let temp_dir = TempDir::new().unwrap(); diff --git a/src/boxlite/src/db/schema.rs b/src/boxlite/src/db/schema.rs index c419be10f..e450b8c8c 100644 --- a/src/boxlite/src/db/schema.rs +++ b/src/boxlite/src/db/schema.rs @@ -7,7 +7,7 @@ //! Each table has queryable columns for efficient filtering + JSON blob for full data. /// Current schema version. -pub const SCHEMA_VERSION: i32 = 9; +pub const SCHEMA_VERSION: i32 = 8; /// Schema version tracking table. pub const SCHEMA_VERSION_TABLE: &str = r#" diff --git a/src/boxlite/src/litebox/archive.rs b/src/boxlite/src/litebox/archive.rs index 5a4e0a88f..e2267ac21 100644 --- a/src/boxlite/src/litebox/archive.rs +++ b/src/boxlite/src/litebox/archive.rs @@ -15,20 +15,20 @@ use crate::disk::constants::filenames as disk_filenames; /// Manifest filename inside the archive. pub(crate) const MANIFEST_FILENAME: &str = "manifest.json"; -/// Baseline archive format version for configurations representable by v3. +/// Archive format version for configurations a v3 importer reads correctly. pub(crate) const ARCHIVE_VERSION: u32 = 3; -/// First archive version that preserves a custom Linux capability policy. +/// First archive version that carries a custom Linux capability policy. +/// +/// A pre-capability importer accepts up to v3 and would silently drop +/// `advanced.capabilities`, starting the box with wider privileges than the +/// archive asked for. Stamping v4 makes that importer refuse the archive. pub(crate) const CAPABILITY_POLICY_ARCHIVE_VERSION: u32 = 4; /// Maximum archive version this build can import. pub(crate) const MAX_SUPPORTED_VERSION: u32 = CAPABILITY_POLICY_ARCHIVE_VERSION; -/// Select the archive format for a box configuration. -/// -/// Kept as a function so fields added to [`crate::runtime::options::BoxOptions`] -/// can opt into a newer compatibility boundary without needlessly changing -/// archives that only use the v3 representation. +/// Pick the archive format an exported box needs. pub(crate) fn archive_version_for_options(options: &crate::runtime::options::BoxOptions) -> u32 { if options.advanced.capabilities.is_empty() { ARCHIVE_VERSION @@ -42,7 +42,7 @@ pub(crate) fn archive_version_for_options(options: &crate::runtime::options::Box /// v1: plain tar, no checksums /// v2: tar.zst with checksums /// v3: adds `box_options` for full configuration preservation -/// v4: `box_options.advanced` may include a custom capability policy +/// v4: `box_options.advanced` carries a custom capability policy #[derive(Debug, Serialize, Deserialize)] pub struct ArchiveManifest { /// Archive format version (1 through 4). @@ -256,10 +256,18 @@ mod tests { use super::*; use tempfile::tempdir; + /// A capability-bearing export must not be readable by an importer that + /// would drop the policy: those archives are stamped v4, ordinary ones v3. + /// + /// The literals are the compatibility boundary itself — a pre-capability + /// importer accepts up to 3 — so pin them, not just the branch. #[test] - fn capability_policy_uses_archive_v4() { + fn only_a_capability_policy_raises_the_archive_version() { + assert_eq!(ARCHIVE_VERSION, 3); + assert_eq!(CAPABILITY_POLICY_ARCHIVE_VERSION, 4); + let ordinary = crate::runtime::options::BoxOptions::default(); - assert_eq!(archive_version_for_options(&ordinary), 3); + assert_eq!(archive_version_for_options(&ordinary), ARCHIVE_VERSION); let custom = crate::runtime::options::BoxOptions { advanced: crate::runtime::advanced_options::AdvancedBoxOptions { @@ -271,7 +279,10 @@ mod tests { }, ..Default::default() }; - assert_eq!(archive_version_for_options(&custom), 4); + assert_eq!( + archive_version_for_options(&custom), + CAPABILITY_POLICY_ARCHIVE_VERSION + ); } #[test] diff --git a/src/boxlite/src/litebox/init/mod.rs b/src/boxlite/src/litebox/init/mod.rs index eb3fed4b4..0f6113dc2 100644 --- a/src/boxlite/src/litebox/init/mod.rs +++ b/src/boxlite/src/litebox/init/mod.rs @@ -172,7 +172,7 @@ impl BoxBuilder { ) -> BoxliteResult { // Get options reference from config (no reconstruction needed!) let options = &config.options; - options.validate()?; + options.sanitize()?; Ok(Self { runtime, diff --git a/src/boxlite/src/rest/runtime.rs b/src/boxlite/src/rest/runtime.rs index a81843475..ef954a928 100644 --- a/src/boxlite/src/rest/runtime.rs +++ b/src/boxlite/src/rest/runtime.rs @@ -13,8 +13,8 @@ use super::client::ApiClient; use super::litebox::RestBox; use super::options::BoxliteRestOptions; use super::types::{ - BoxResponse, CreateBoxRequest, CreateVolumeRequest, GetOrCreateBoxResponse, ListBoxesResponse, - ListVolumesResponse, RuntimeMetricsResponse, VolumeResponse, + BoxResponse, CreateBoxRequest, CreateVolumeRequest, ListBoxesResponse, ListVolumesResponse, + RuntimeMetricsResponse, VolumeResponse, }; use crate::runtime::auth::{AuthBackend, Principal}; use crate::runtime::volumes::VolumeBackend; @@ -29,41 +29,6 @@ impl RestRuntime { let client = ApiClient::new(config)?; Ok(Self { client }) } - - async fn create_with_contract( - &self, - options: BoxOptions, - name: Option, - ) -> BoxliteResult { - // Validate only the caller's requested policy. An unset auto_pause means - // "no auto-pause", so it must not borrow the server's default here. - crate::runtime::types::BoxLifecyclePolicy { - auto_pause: options.auto_pause.unwrap_or(0), - auto_delete: options.auto_delete.unwrap_or(0), - auto_resume: options.auto_resume.unwrap_or(true), - } - .validate()?; - - let has_capability_policy = !options.advanced.capabilities.is_empty(); - if has_capability_policy { - self.client.require_linux_capabilities_enabled().await?; - } - - let req = CreateBoxRequest::from_options(&options, name); - // The strict route was introduced with capability policy support. An - // older API instance returns 404 rather than accepting security-sensitive - // fields it does not understand. - let uses_strict_contract = has_capability_policy; - let create_path = if uses_strict_contract { - "/boxes/strict" - } else { - "/boxes" - }; - let resp: BoxResponse = self.client.post(create_path, &req).await?; - let info = resp.to_box_info()?; - let rest_box = Arc::new(RestBox::new(self.client.clone(), info)); - Ok(litebox_from_rest(rest_box)) - } } #[async_trait::async_trait] @@ -116,7 +81,28 @@ fn litebox_from_rest(rest_box: Arc) -> LiteBox { #[async_trait::async_trait] impl RuntimeBackend for RestRuntime { async fn create(&self, options: BoxOptions, name: Option) -> BoxliteResult { - self.create_with_contract(options, name).await + // Validate only the caller's requested policy. An unset auto_pause means + // "no auto-pause", so it must not borrow the server's default here — + // otherwise a plain remove-on-stop box (`--rm` → auto_delete=1) is + // wrongly rejected by the ordering check before the request is even sent. + crate::runtime::types::BoxLifecyclePolicy { + auto_pause: options.auto_pause.unwrap_or(0), + auto_delete: options.auto_delete.unwrap_or(0), + auto_resume: options.auto_resume.unwrap_or(true), + } + .validate()?; + + // A server that does not advertise the capability policy would accept + // the request and drop the field, silently granting default privileges. + if !options.advanced.capabilities.is_empty() { + self.client.require_linux_capabilities_enabled().await?; + } + + let req = CreateBoxRequest::from_options(&options, name); + let resp: BoxResponse = self.client.post("/boxes", &req).await?; + let info = resp.to_box_info()?; + let rest_box = Arc::new(RestBox::new(self.client.clone(), info)); + Ok(litebox_from_rest(rest_box)) } async fn get_or_create( @@ -124,29 +110,14 @@ impl RuntimeBackend for RestRuntime { options: BoxOptions, name: Option, ) -> BoxliteResult<(LiteBox, bool)> { - if name.is_some() { - crate::runtime::types::BoxLifecyclePolicy { - auto_pause: options.auto_pause.unwrap_or(0), - auto_delete: options.auto_delete.unwrap_or(0), - auto_resume: options.auto_resume.unwrap_or(true), - } - .validate()?; - - if !options.advanced.capabilities.is_empty() { - self.client.require_linux_capabilities_enabled().await?; - } - - let request = CreateBoxRequest::from_options(&options, name); - let response: GetOrCreateBoxResponse = self - .client - .post("/boxes/get-or-create/strict", &request) - .await?; - let info = response.box_info.to_box_info()?; - let rest_box = Arc::new(RestBox::new(self.client.clone(), info)); - return Ok((litebox_from_rest(rest_box), response.created)); + // Try to get existing box by name first + if let Some(ref box_name) = name + && let Some(litebox) = self.get(box_name).await? + { + return Ok((litebox, false)); } - - let litebox = self.create_with_contract(options, name).await?; + // Create new box + let litebox = self.create(options, name).await?; Ok((litebox, true)) } @@ -266,6 +237,21 @@ mod tests { use tokio::io::{AsyncReadExt, AsyncWriteExt}; use tokio::net::TcpListener; + const BOX_RESPONSE: &str = r#"{"box_id":"01HJK4TNRPQSXYZ8WM6NCVT9R5","name":"named","status":"configured","created_at":"2026-01-01T00:00:00Z","updated_at":"2026-01-01T00:00:00Z","pid":null,"image":"alpine:latest","cpus":2,"memory_mib":512,"labels":{}}"#; + + fn capability_options() -> BoxOptions { + BoxOptions { + advanced: crate::AdvancedBoxOptions { + capabilities: crate::ContainerCapabilities { + drop: vec!["NET_RAW".into()], + ..Default::default() + }, + ..Default::default() + }, + ..Default::default() + } + } + async fn json_server(bodies: Vec<&'static str>) -> (u16, tokio::task::JoinHandle>) { let listener = TcpListener::bind(("127.0.0.1", 0)).await.unwrap(); let port = listener.local_addr().unwrap().port(); @@ -342,253 +328,76 @@ mod tests { #[tokio::test] async fn custom_capabilities_require_server_advertisement_before_create() { - let listener = TcpListener::bind(("127.0.0.1", 0)).await.unwrap(); - let port = listener.local_addr().unwrap().port(); - let server = tokio::spawn(async move { - let (mut socket, _) = listener.accept().await.unwrap(); - let mut headers = Vec::new(); - while !headers.ends_with(b"\r\n\r\n") { - headers.push(socket.read_u8().await.unwrap()); - } - let request = String::from_utf8(headers).unwrap(); - let body = r#"{"capabilities":{}}"#; - socket - .write_all( - format!( - "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}", - body.len(), - body - ) - .as_bytes(), - ) - .await - .unwrap(); - request.lines().next().unwrap().to_string() - }); - + // A server that does not advertise the feature would accept the create + // request and drop `advanced`, silently granting default privileges. + let (port, server) = json_server(vec![r#"{"capabilities":{}}"#]).await; let runtime = RestRuntime::new(&BoxliteRestOptions::new(format!("http://127.0.0.1:{port}"))).unwrap(); - let result = RuntimeBackend::create( - &runtime, - BoxOptions { - advanced: crate::AdvancedBoxOptions { - capabilities: crate::ContainerCapabilities { - drop: vec!["NET_RAW".into()], - ..Default::default() - }, - ..Default::default() - }, - ..Default::default() - }, - None, - ) - .await; - let error = match result { + + let error = match RuntimeBackend::create(&runtime, capability_options(), None).await { Err(error) => error, Ok(_) => panic!("an old server must not silently ignore a capability policy"), }; assert!(matches!(error, BoxliteError::Unsupported(_))); - assert_eq!(server.await.unwrap(), "GET /v1/config HTTP/1.1"); + assert_eq!(server.await.unwrap(), ["GET /v1/config HTTP/1.1"]); } #[tokio::test] - async fn custom_capabilities_use_strict_create_route() { - let listener = TcpListener::bind(("127.0.0.1", 0)).await.unwrap(); - let port = listener.local_addr().unwrap().port(); - let server = tokio::spawn(async move { - let mut requests = Vec::new(); - for body in [ - r#"{"capabilities":{"linux_capabilities_enabled":true}}"#, - r#"{"box_id":"01HJK4TNRPQSXYZ8WM6NCVT9R5","name":null,"status":"configured","created_at":"2026-01-01T00:00:00Z","updated_at":"2026-01-01T00:00:00Z","pid":null,"image":"alpine:latest","cpus":2,"memory_mib":512,"labels":{}}"#, - ] { - let (mut socket, _) = listener.accept().await.unwrap(); - let mut headers = Vec::new(); - while !headers.ends_with(b"\r\n\r\n") { - headers.push(socket.read_u8().await.unwrap()); - } - let request = String::from_utf8(headers).unwrap(); - requests.push(request.lines().next().unwrap().to_string()); - socket - .write_all( - format!( - "HTTP/1.1 201 Created\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}", - body.len(), - body - ) - .as_bytes(), - ) - .await - .unwrap(); - } - requests - }); - - let runtime = - RestRuntime::new(&BoxliteRestOptions::new(format!("http://127.0.0.1:{port}"))).unwrap(); - RuntimeBackend::create( - &runtime, - BoxOptions { - advanced: crate::AdvancedBoxOptions { - capabilities: crate::ContainerCapabilities { - add: vec!["SYS_ADMIN".into()], - ..Default::default() - }, - ..Default::default() - }, - ..Default::default() - }, - None, - ) - .await - .unwrap(); - - assert_eq!( - server.await.unwrap(), - ["GET /v1/config HTTP/1.1", "POST /v1/boxes/strict HTTP/1.1"] - ); - } - - #[tokio::test] - async fn strict_create_does_not_recheck_server_options() { + async fn advertised_capability_support_creates_on_the_shared_route() { let (port, server) = json_server(vec![ r#"{"capabilities":{"linux_capabilities_enabled":true}}"#, - r#"{"box_id":"01HJK4TNRPQSXYZ8WM6NCVT9R5","name":null,"status":"configured","created_at":"2026-01-01T00:00:00Z","updated_at":"2026-01-01T00:00:00Z","pid":null,"image":"alpine:latest","cpus":2,"memory_mib":512,"labels":{}}"#, + BOX_RESPONSE, ]) .await; let runtime = RestRuntime::new(&BoxliteRestOptions::new(format!("http://127.0.0.1:{port}"))).unwrap(); - RuntimeBackend::create( - &runtime, - BoxOptions { - advanced: crate::AdvancedBoxOptions { - capabilities: crate::ContainerCapabilities { - add: vec!["NET_ADMIN".into()], - ..Default::default() - }, - ..Default::default() - }, - ..Default::default() - }, - None, - ) - .await - .unwrap(); + + RuntimeBackend::create(&runtime, capability_options(), None) + .await + .expect("create with a capability policy"); + assert_eq!( server.await.unwrap(), - ["GET /v1/config HTTP/1.1", "POST /v1/boxes/strict HTTP/1.1"] + ["GET /v1/config HTTP/1.1", "POST /v1/boxes HTTP/1.1"] ); } #[tokio::test] - async fn strict_create_accepts_response_without_capability_policy() { - let listener = TcpListener::bind(("127.0.0.1", 0)).await.unwrap(); - let port = listener.local_addr().unwrap().port(); - let server = tokio::spawn(async move { - for body in [ - r#"{"capabilities":{"linux_capabilities_enabled":true}}"#, - r#"{"box_id":"01HJK4TNRPQSXYZ8WM6NCVT9R5","name":null,"status":"configured","created_at":"2026-01-01T00:00:00Z","updated_at":"2026-01-01T00:00:00Z","pid":null,"image":"alpine:latest","cpus":2,"memory_mib":512,"labels":{}}"#, - ] { - let (mut socket, _) = listener.accept().await.unwrap(); - let mut headers = Vec::new(); - while !headers.ends_with(b"\r\n\r\n") { - headers.push(socket.read_u8().await.unwrap()); - } - socket - .write_all( - format!( - "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}", - body.len(), - body - ) - .as_bytes(), - ) - .await - .unwrap(); - } - }); - + async fn ordinary_create_does_not_probe_server_capabilities() { + let (port, server) = json_server(vec![BOX_RESPONSE]).await; let runtime = RestRuntime::new(&BoxliteRestOptions::new(format!("http://127.0.0.1:{port}"))).unwrap(); - RuntimeBackend::create( - &runtime, - BoxOptions { - advanced: crate::AdvancedBoxOptions { - capabilities: crate::ContainerCapabilities { - drop: vec!["NET_RAW".into()], - ..Default::default() - }, - ..Default::default() - }, - ..Default::default() - }, - None, - ) - .await - .unwrap(); - server.await.unwrap(); - } - - #[tokio::test] - async fn get_or_create_accepts_response_without_capability_policy() { - let listener = TcpListener::bind(("127.0.0.1", 0)).await.unwrap(); - let port = listener.local_addr().unwrap().port(); - let server = tokio::spawn(async move { - let (mut socket, _) = listener.accept().await.unwrap(); - let mut headers = Vec::new(); - while !headers.ends_with(b"\r\n\r\n") { - headers.push(socket.read_u8().await.unwrap()); - } - let request = String::from_utf8(headers).unwrap(); - let body = r#"{"box_info":{"box_id":"01HJK4TNRPQSXYZ8WM6NCVT9R5","name":"named","status":"configured","created_at":"2026-01-01T00:00:00Z","updated_at":"2026-01-01T00:00:00Z","pid":null,"image":"alpine:latest","cpus":2,"memory_mib":512,"labels":{}},"created":false}"#; - socket - .write_all( - format!( - "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}", - body.len(), - body - ) - .as_bytes(), - ) - .await - .unwrap(); - request.lines().next().unwrap().to_string() - }); - let runtime = - RestRuntime::new(&BoxliteRestOptions::new(format!("http://127.0.0.1:{port}"))).unwrap(); - let (_, created) = - RuntimeBackend::get_or_create(&runtime, BoxOptions::default(), Some("named".into())) - .await - .unwrap(); + RuntimeBackend::create(&runtime, BoxOptions::default(), None) + .await + .expect("create without a capability policy"); - assert!(!created); - assert_eq!( - server.await.unwrap(), - "POST /v1/boxes/get-or-create/strict HTTP/1.1" - ); + assert_eq!(server.await.unwrap(), ["POST /v1/boxes HTTP/1.1"]); } + /// Inspection reads the shared routes. Addressing a versioned variant here + /// would 404, and `get` maps NotFound to `Ok(None)` — so an existing box + /// would silently report as missing rather than failing loudly. #[tokio::test] - async fn get_or_create_does_not_recheck_server_options() { - let (port, server) = json_server(vec![ - r#"{"box_info":{"box_id":"01HJK4TNRPQSXYZ8WM6NCVT9R5","name":"named","status":"configured","created_at":"2026-01-01T00:00:00Z","updated_at":"2026-01-01T00:00:00Z","pid":null,"image":"alpine:latest","cpus":2,"memory_mib":512,"labels":{}},"created":false}"#, - ]) - .await; + async fn inspection_uses_the_shared_box_routes() { + let (port, server) = json_server(vec![BOX_RESPONSE, BOX_RESPONSE]).await; let runtime = RestRuntime::new(&BoxliteRestOptions::new(format!("http://127.0.0.1:{port}"))).unwrap(); - let (_, created) = RuntimeBackend::get_or_create( - &runtime, - BoxOptions::default(), - Some("named".to_string()), - ) - .await - .unwrap(); - assert!(!created); + let found = RuntimeBackend::get(&runtime, "named").await.expect("get"); + let info = RuntimeBackend::get_info(&runtime, "named") + .await + .expect("get_info"); + + assert!(found.is_some(), "an existing box must not read as missing"); + assert!(info.is_some(), "an existing box must expose its info"); assert_eq!( server.await.unwrap(), - ["POST /v1/boxes/get-or-create/strict HTTP/1.1"] + [ + "GET /v1/boxes/named HTTP/1.1", + "GET /v1/boxes/named HTTP/1.1" + ] ); } } diff --git a/src/boxlite/src/rest/types.rs b/src/boxlite/src/rest/types.rs index 7ef05781b..f414dc474 100644 --- a/src/boxlite/src/rest/types.rs +++ b/src/boxlite/src/rest/types.rs @@ -269,12 +269,6 @@ pub(crate) struct BoxResponse { pub auto_resume: bool, } -#[derive(Debug, Deserialize)] -pub(crate) struct GetOrCreateBoxResponse { - pub box_info: BoxResponse, - pub created: bool, -} - impl BoxResponse { pub fn to_box_info(&self) -> boxlite_shared::errors::BoxliteResult { use crate::runtime::id::BoxID; diff --git a/src/boxlite/src/runtime/advanced_options.rs b/src/boxlite/src/runtime/advanced_options.rs index ede43715b..02a990502 100644 --- a/src/boxlite/src/runtime/advanced_options.rs +++ b/src/boxlite/src/runtime/advanced_options.rs @@ -569,7 +569,7 @@ impl SecurityOptionsBuilder { /// explicit addition wins; with `add = ["ALL"]`, named removals win. With /// `drop = ["ALL"]`, explicit additions form the complete resulting set. #[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] -#[serde(default, deny_unknown_fields)] +#[serde(default)] pub struct ContainerCapabilities { /// Capabilities to add to BoxLite's Docker-compatible baseline. pub add: Vec, @@ -589,7 +589,7 @@ impl ContainerCapabilities { validate_capability_names("advanced.capabilities.drop", &self.drop) } - /// Check the requested policy against the policy reported for a box. + /// Check the requested policy against the one recorded for an existing box. pub(crate) fn check_compatibility( &self, actual: &Self, @@ -598,17 +598,7 @@ impl ContainerCapabilities { let canonicalize = |capabilities: &[String]| { capabilities .iter() - .map(|capability| { - let normalized = capability.to_ascii_uppercase(); - if normalized == "ALL" { - normalized - } else { - normalized - .strip_prefix("CAP_") - .unwrap_or(&normalized) - .to_string() - } - }) + .map(|capability| canonical_capability_name(capability)) .collect::>() }; @@ -620,24 +610,35 @@ impl ContainerCapabilities { Err(boxlite_shared::errors::BoxliteError::InvalidArgument( format!( - "requested capability policy does not match the authoritative policy for box \ - '{box_name}'" + "box '{box_name}' already exists with a different capability policy; reuse it \ + with the same advanced.capabilities, or create a box under a new name" ), )) } } +/// Uppercase a capability and strip its optional `CAP_` prefix. +/// +/// Validation and reuse comparison share one canonical form so they cannot +/// disagree about whether `net_raw` and `CAP_NET_RAW` are the same policy. +fn canonical_capability_name(capability: &str) -> String { + let normalized = capability.to_ascii_uppercase(); + normalized + .strip_prefix("CAP_") + .unwrap_or(&normalized) + .to_string() +} + fn validate_capability_names( option: &str, capabilities: &[String], ) -> boxlite_shared::errors::BoxliteResult<()> { for capability in capabilities { - let normalized = capability.to_ascii_uppercase(); - if normalized == "ALL" { + let name = canonical_capability_name(capability); + if name == "ALL" { continue; } - let name = normalized.strip_prefix("CAP_").unwrap_or(&normalized); if name.is_empty() { return Err(boxlite_shared::errors::BoxliteError::InvalidArgument( format!("empty Linux capability in {option}"), @@ -662,7 +663,6 @@ fn validate_capability_names( /// Entry-level users can ignore this — the defaults are secure and sensible. /// Only modify these if you understand the security implications. #[derive(Clone, Debug, Default, Serialize, Deserialize)] -#[serde(deny_unknown_fields)] pub struct AdvancedBoxOptions { /// Linux capability policy for the container process. #[serde(default)] diff --git a/src/boxlite/src/runtime/core.rs b/src/boxlite/src/runtime/core.rs index d4d06c979..311f76da4 100644 --- a/src/boxlite/src/runtime/core.rs +++ b/src/boxlite/src/runtime/core.rs @@ -280,7 +280,7 @@ impl BoxliteRuntime { ) -> BoxliteResult { // Reject incompatible option combinations at the create boundary (fail // here, not at start), uniformly for the local and REST backends. - options.validate()?; + options.sanitize()?; self.backend.create(options, name).await } @@ -295,7 +295,7 @@ impl BoxliteRuntime { options: BoxOptions, name: Option, ) -> BoxliteResult<(LiteBox, bool)> { - options.validate()?; + options.sanitize()?; self.backend.get_or_create(options, name).await } diff --git a/src/boxlite/src/runtime/import.rs b/src/boxlite/src/runtime/import.rs index b370924c7..e5570bd30 100644 --- a/src/boxlite/src/runtime/import.rs +++ b/src/boxlite/src/runtime/import.rs @@ -8,8 +8,8 @@ use boxlite_shared::errors::{BoxliteError, BoxliteResult}; use crate::disk::constants::filenames as disk_filenames; use crate::litebox::LiteBox; use crate::litebox::archive::{ - ArchiveManifest, CAPABILITY_POLICY_ARCHIVE_VERSION, MANIFEST_FILENAME, MAX_SUPPORTED_VERSION, - extract_archive, move_file, sha256_file, + ArchiveManifest, MANIFEST_FILENAME, MAX_SUPPORTED_VERSION, extract_archive, move_file, + sha256_file, }; use crate::runtime::options::{BoxArchive, BoxOptions, RootfsSpec}; use crate::runtime::rt_impl::RuntimeImpl; @@ -67,20 +67,15 @@ pub(crate) async fn import_box( } /// Read the persisted configuration, falling back to the v1/v2 image field. +/// +/// An archive is untrusted input, so its options are validated here rather +/// than after disks have been installed and box metadata persisted. fn options_from_manifest(manifest: &ArchiveManifest) -> BoxliteResult { let options = manifest.box_options.clone().unwrap_or_else(|| BoxOptions { rootfs: RootfsSpec::Image(manifest.image.clone()), ..Default::default() }); - if manifest.version < CAPABILITY_POLICY_ARCHIVE_VERSION - && !options.advanced.capabilities.is_empty() - { - return Err(BoxliteError::InvalidArgument(format!( - "archive capability policy requires manifest version {} or newer", - CAPABILITY_POLICY_ARCHIVE_VERSION - ))); - } - options.validate().map_err(|error| { + options.sanitize().map_err(|error| { BoxliteError::InvalidArgument(format!("invalid archive box_options: {error}")) })?; Ok(options) @@ -205,7 +200,7 @@ mod tests { #[test] fn imported_capability_policy_is_validated_before_install() { let manifest = ArchiveManifest { - version: 4, + version: 3, box_name: Some("untrusted".into()), image: "alpine:latest".into(), box_options: Some(BoxOptions { @@ -229,33 +224,6 @@ mod tests { assert!(error.to_string().contains("NET-ADMIN")); } - #[test] - fn archive_v3_cannot_smuggle_a_capability_policy() { - let manifest = ArchiveManifest { - version: 3, - box_name: Some("mislabeled".into()), - image: "alpine:latest".into(), - box_options: Some(BoxOptions { - advanced: crate::runtime::advanced_options::AdvancedBoxOptions { - capabilities: crate::runtime::advanced_options::ContainerCapabilities { - drop: vec!["ALL".into()], - ..Default::default() - }, - ..Default::default() - }, - ..Default::default() - }), - guest_disk_checksum: String::new(), - container_disk_checksum: String::new(), - exported_at: "2026-01-01T00:00:00Z".into(), - }; - - let error = options_from_manifest(&manifest) - .expect_err("v3 archives must not carry v4 capability policy fields"); - assert!(matches!(error, BoxliteError::InvalidArgument(_))); - assert!(error.to_string().contains("version 4")); - } - #[test] fn test_validate_no_backing_references_rejects_absolute() { let dir = TempDir::new_in("/tmp").unwrap(); diff --git a/src/boxlite/src/runtime/options.rs b/src/boxlite/src/runtime/options.rs index 6e6106461..c76aaa0f3 100644 --- a/src/boxlite/src/runtime/options.rs +++ b/src/boxlite/src/runtime/options.rs @@ -312,7 +312,7 @@ mod registry_options_tests { /// Options used when constructing a box. #[derive(Clone, Debug, serde::Serialize, serde::Deserialize)] -#[serde(default, deny_unknown_fields)] +#[serde(default)] pub struct BoxOptions { pub cpus: Option, pub memory_mib: Option, @@ -550,14 +550,14 @@ impl BoxOptions { self.effective_auto_delete() > 0 } - /// Validate options before they enter a runtime backend. + /// Sanitize and validate options. /// /// Validates option combinations: /// - effective remove-on-stop (`auto_delete>0`, or deprecated `auto_remove`) /// with `detach=true` is invalid /// - `advanced.isolate_mounts=true` is only supported on Linux /// - `advanced.capabilities` contains well-formed Linux capability names - pub fn validate(&self) -> BoxliteResult<()> { + pub fn sanitize(&self) -> BoxliteResult<()> { if self.removes_on_stop() && self.detach { return Err(boxlite_shared::errors::BoxliteError::Config( "remove-on-stop is incompatible with detach=true. Detached boxes should use \ @@ -577,13 +577,6 @@ impl BoxOptions { Ok(()) } - - /// Backward-compatible name for [`Self::validate`]. - /// - /// This method validates options without modifying them. - pub fn sanitize(&self) -> BoxliteResult<()> { - self.validate() - } } /// How to populate the box root filesystem. @@ -815,35 +808,7 @@ mod tests { } #[test] - fn box_options_serde_rejects_unknown_flat_capability_fields() { - let error = serde_json::from_str::(r#"{"cap_drop":["NET_RAW"]}"#) - .expect_err("flat capability fields must not be silently ignored"); - - assert!(error.to_string().contains("cap_drop")); - } - - #[test] - fn advanced_options_serde_rejects_misspelled_capabilities_field() { - let error = serde_json::from_str::( - r#"{"advanced":{"capabilites":{"drop":["NET_RAW"]}}}"#, - ) - .expect_err("misspelled advanced capability fields must not be silently ignored"); - - assert!(error.to_string().contains("capabilites")); - } - - #[test] - fn container_capabilities_serde_rejects_misspelled_drop_field() { - let error = serde_json::from_str::( - r#"{"advanced":{"capabilities":{"dorp":["NET_RAW"]}}}"#, - ) - .expect_err("misspelled capability policy fields must not be silently ignored"); - - assert!(error.to_string().contains("dorp")); - } - - #[test] - fn box_options_validate_accepts_valid_capability_names() { + fn box_options_sanitize_accepts_valid_capability_names() { let opts = BoxOptions { advanced: AdvancedBoxOptions { capabilities: ContainerCapabilities { @@ -855,12 +820,12 @@ mod tests { ..Default::default() }; - opts.validate() + opts.sanitize() .expect("Docker-style capability names should be accepted"); } #[test] - fn box_options_validate_accepts_future_capability_names() { + fn box_options_sanitize_accepts_future_capability_names() { let opts = BoxOptions { advanced: AdvancedBoxOptions { capabilities: ContainerCapabilities { @@ -872,12 +837,12 @@ mod tests { ..Default::default() }; - opts.validate() + opts.sanitize() .expect("the guest runtime, not the host SDK, owns the supported capability list"); } #[test] - fn box_options_validate_rejects_malformed_capability_names() { + fn box_options_sanitize_rejects_malformed_capability_names() { for opts in [ BoxOptions { advanced: AdvancedBoxOptions { @@ -921,7 +886,7 @@ mod tests { }, ] { let err = opts - .validate() + .sanitize() .expect_err("malformed capability should be rejected"); assert_eq!(err.http().0, 400); let err = err.to_string(); @@ -1062,40 +1027,36 @@ mod tests { } #[test] - fn test_validate_remove_on_stop_detach_incompatible() { + fn test_sanitize_remove_on_stop_detach_incompatible() { let opts = BoxOptions { auto_delete: Some(1), detach: true, ..Default::default() }; - let err_msg = opts.validate().unwrap_err().to_string(); + let err_msg = opts.sanitize().unwrap_err().to_string(); assert!(err_msg.contains("incompatible")); } #[test] - fn test_validate_valid_combinations() { + fn test_sanitize_valid_combinations() { let remove = BoxOptions { auto_delete: Some(1), ..Default::default() }; - assert!(remove.validate().is_ok()); + assert!(remove.sanitize().is_ok()); let keep_detached = BoxOptions { auto_delete: Some(0), detach: true, ..Default::default() }; - assert!(keep_detached.validate().is_ok()); + assert!(keep_detached.sanitize().is_ok()); let keep_attached = BoxOptions { auto_delete: Some(0), ..Default::default() }; - assert!(keep_attached.validate().is_ok()); - - BoxOptions::default() - .sanitize() - .expect("the legacy sanitize name should continue to validate options"); + assert!(keep_attached.sanitize().is_ok()); } // ======================================================================== diff --git a/src/boxlite/src/runtime/rt_impl.rs b/src/boxlite/src/runtime/rt_impl.rs index e30faadc9..e4bdca632 100644 --- a/src/boxlite/src/runtime/rt_impl.rs +++ b/src/boxlite/src/runtime/rt_impl.rs @@ -471,10 +471,10 @@ impl RuntimeImpl { Ok((litebox_from_impl(box_impl), false)) } - /// Check requested options before adopting an existing local box. + /// Reject reuse when the request disagrees with the box's stored options. /// - /// Comparisons for additional immutable options belong here so each - /// runtime backend owns its reuse policy. + /// Only the capability policy is compared: silently adopting a box whose + /// privileges differ from the request is the case that matters for safety. fn check_options_compatibility( requested: &BoxOptions, actual: &BoxConfig, diff --git a/src/cli/src/commands/serve/handlers/boxes.rs b/src/cli/src/commands/serve/handlers/boxes.rs index 11b63d39a..904b2cdf0 100644 --- a/src/cli/src/commands/serve/handlers/boxes.rs +++ b/src/cli/src/commands/serve/handlers/boxes.rs @@ -7,9 +7,7 @@ use axum::extract::{Path, Query, State}; use axum::http::StatusCode; use axum::response::{IntoResponse, Response}; -use super::super::types::{ - CreateBoxRequest, GetOrCreateBoxResponse, ListBoxesResponse, RemoveQuery, -}; +use super::super::types::{CreateBoxRequest, ListBoxesResponse, RemoveQuery}; use super::super::{ AppState, box_info_to_response, build_box_options, error_from_boxlite, error_response, get_or_fetch_box, @@ -19,58 +17,6 @@ pub(in crate::commands::serve) async fn create_box( State(state): State>, Json(req): Json, ) -> Response { - create_box_inner(state, req).await -} - -pub(in crate::commands::serve) async fn create_box_legacy( - State(state): State>, - Json(req): Json, -) -> Response { - if req.advanced.is_present { - return error_response( - StatusCode::BAD_REQUEST, - "advanced capabilities require POST /v1/boxes/strict".to_string(), - "InvalidArgumentError", - "invalid_argument", - ); - } - create_box_inner(state, req).await -} - -pub(in crate::commands::serve) async fn get_or_create_box( - State(state): State>, - Json(req): Json, -) -> Response { - let name = req.name.clone(); - let options = match build_box_options(&req) { - Ok(options) => options, - Err(error) => { - return error_response( - StatusCode::BAD_REQUEST, - error.to_string(), - "InvalidArgumentError", - "invalid_argument", - ); - } - }; - - let (litebox, created) = match state.runtime.get_or_create(options, name).await { - Ok(result) => result, - Err(error) => return error_from_boxlite(&error), - }; - - let info = litebox.info(); - let box_id = info.id.to_string(); - let response = GetOrCreateBoxResponse { - box_info: box_info_to_response(&info), - created, - }; - state.boxes.write().await.insert(box_id, Arc::new(litebox)); - - (StatusCode::OK, Json(response)).into_response() -} - -async fn create_box_inner(state: Arc, req: CreateBoxRequest) -> Response { let name = req.name.clone(); let options = match build_box_options(&req) { Ok(options) => options, diff --git a/src/cli/src/commands/serve/mod.rs b/src/cli/src/commands/serve/mod.rs index b171202b4..d18a042c5 100644 --- a/src/cli/src/commands/serve/mod.rs +++ b/src/cli/src/commands/serve/mod.rs @@ -1089,17 +1089,9 @@ fn build_router(state: Arc) -> Router { ) // Box CRUD (import first — static path before param path) .route("/v1/boxes/import", post(advanced::import_box)) - .route( - "/v1/boxes/get-or-create/strict", - post(boxes::get_or_create_box), - ) - .route( - "/v1/boxes/strict", - post(boxes::create_box), - ) .route( "/v1/boxes", - post(boxes::create_box_legacy).get(boxes::list_boxes), + post(boxes::create_box).get(boxes::list_boxes), ) .route( "/v1/boxes/{box_id}", diff --git a/src/cli/src/commands/serve/types.rs b/src/cli/src/commands/serve/types.rs index 85d6bab37..7346ab7ae 100644 --- a/src/cli/src/commands/serve/types.rs +++ b/src/cli/src/commands/serve/types.rs @@ -39,11 +39,9 @@ pub(super) struct CreateBoxRequest { /// add one. #[serde(default)] pub tty: Option, - /// Expert-only container options. A small wrapper records presence so the - /// legacy route can reject this newer contract even when the object is - /// empty. Explicit `null` is rejected by the nested deserializer. + /// Expert-only container options. #[serde(default)] - pub advanced: AdvancedRequestField, + pub advanced: CreateBoxAdvancedOptions, #[serde(default)] pub network: Option, #[serde(default)] @@ -64,29 +62,10 @@ pub(super) struct CreateBoxRequest { // below for the wire-shape pin. } -#[derive(Default)] -pub(super) struct AdvancedRequestField { - pub is_present: bool, - pub capabilities: ContainerCapabilitiesRequest, -} - -impl<'de> Deserialize<'de> for AdvancedRequestField { - fn deserialize(deserializer: D) -> Result - where - D: serde::Deserializer<'de>, - { - let value = CreateBoxAdvancedOptions::deserialize(deserializer)?; - Ok(Self { - is_present: true, - capabilities: value.capabilities, - }) - } -} - #[derive(Default, Deserialize)] #[serde(default, deny_unknown_fields)] -struct CreateBoxAdvancedOptions { - capabilities: ContainerCapabilitiesRequest, +pub(super) struct CreateBoxAdvancedOptions { + pub capabilities: ContainerCapabilitiesRequest, } #[derive(Clone, Default, Deserialize)] @@ -126,12 +105,6 @@ pub(super) struct BoxResponse { pub exit_code: Option, } -#[derive(Serialize)] -pub(super) struct GetOrCreateBoxResponse { - pub box_info: BoxResponse, - pub created: bool, -} - #[derive(Serialize)] pub(super) struct ListBoxesResponse { pub boxes: Vec, diff --git a/src/shared/proto/boxlite/v1/service.proto b/src/shared/proto/boxlite/v1/service.proto index 59dc0296d..fb72d72dd 100644 --- a/src/shared/proto/boxlite/v1/service.proto +++ b/src/shared/proto/boxlite/v1/service.proto @@ -312,7 +312,7 @@ message ContainerConfig { bool tty = 5; // Expert-only container process options. - ContainerAdvancedOptions advanced = 8; + ContainerAdvancedOptions advanced = 6; } message ContainerAdvancedOptions { From 84eb3dfb13e680e00ebe1b7e574b0548af08be38 Mon Sep 17 00:00:00 2001 From: tester Date: Mon, 27 Jul 2026 23:24:03 +0800 Subject: [PATCH 09/10] refactor: gate capability policy on guest version, not a feature list MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The guest already reports its build version on Ping, so a second advertisement channel adds a field without adding information. Drop `PingResponse.features` and compare the reported version instead. The floor is 0.9.8. v0.9.7 is released and its guest predates the `advanced.capabilities` field, so it would decode the field as unknown proto and drop it — accepting that guest is the fail-open this gate exists to prevent. A guest rootfs is cached per version and reused, so such a guest can outlive its release and meet a much newer host. Versions that do not parse are rejected rather than assumed current; a pre-release suffix compares as its numeric core, since a `0.9.8-rc1` guest is built from the tree that carries the field. Because the workspace is still 0.9.7, a guest built from this tree cannot satisfy its own floor, so custom capabilities do not work in tree until the release bump. Drop the end-to-end test that asserted them. Unit tests still cover resolution, the TTY-exec OCI process and the API boundaries, but nothing now exercises the init and non-TTY exec paths that test owned. Restore it with the bump. --- docs/architecture/container-capabilities.md | 20 +++-- .../src/litebox/init/tasks/guest_init.rs | 9 ++- src/boxlite/src/portal/interfaces/guest.rs | 79 ++++++++++++++----- src/boxlite/tests/security_enforcement.rs | 71 ----------------- src/guest/src/service/guest.rs | 3 - src/shared/proto/boxlite/v1/service.proto | 3 - src/shared/src/constants.rs | 6 -- 7 files changed, 80 insertions(+), 111 deletions(-) diff --git a/docs/architecture/container-capabilities.md b/docs/architecture/container-capabilities.md index 5f6e37dbc..af4a04ad4 100644 --- a/docs/architecture/container-capabilities.md +++ b/docs/architecture/container-capabilities.md @@ -57,15 +57,19 @@ silently dropped: - A remote SDK re-reads `linux_capabilities_enabled` from `GET /v1/config` (uncached) immediately before creating a box with a custom policy, so a server rollback cannot be masked by a stale discovery cache. -- A BoxLite host requires the guest's `linux-capabilities-v2` Ping feature - before sending the nested policy. +- A BoxLite host requires the guest to report version 0.9.8 or newer from + Ping before sending the nested policy. Guest rootfs images are cached per + version and reused, so an older guest can outlive its release; it would + decode the new field as unknown proto and drop it. - The cloud control plane schedules capability-bearing boxes only onto runners - advertising the same feature, and re-checks it before start and recovery. - -A missing advertisement therefore fails closed. Boundaries that do not carry a -custom policy are unaffected: ordinary create, get, and list keep working -against any server version. Inspection does not report the policy — it is -create-time configuration, not box state. + advertising `linux-capabilities-v2`, and re-checks it before start and + recovery. That token is the runner's own, independent of the guest version + the host checks. + +A missing advertisement or too-old guest therefore fails closed. Boundaries +that do not carry a custom policy are unaffected: ordinary create, get, and +list keep working against any server version. Inspection does not report the +policy — it is create-time configuration, not box state. Named `get_or_create` on the local runtime refuses to adopt an existing box whose capability policy differs from the requested one, so reuse cannot diff --git a/src/boxlite/src/litebox/init/tasks/guest_init.rs b/src/boxlite/src/litebox/init/tasks/guest_init.rs index f3a31d8ce..75acbf292 100644 --- a/src/boxlite/src/litebox/init/tasks/guest_init.rs +++ b/src/boxlite/src/litebox/init/tasks/guest_init.rs @@ -14,6 +14,13 @@ use crate::portal::interfaces::{ContainerInitConfig, GuestInitConfig, NetworkIni use async_trait::async_trait; use boxlite_shared::errors::{BoxliteError, BoxliteResult}; +/// Oldest guest release that honors `advanced.capabilities` on +/// `Container.Init`. An earlier guest drops the field and starts the container +/// with the default capability set while the caller believes a policy applied. +/// Guest rootfs images are cached per version and reused, so a host can meet +/// one long after its own release. +const MIN_CAPABILITY_GUEST_VERSION: crate::portal::interfaces::guest::GuestVersion = (0, 9, 8); + pub struct GuestInitTask; struct GuestBootstrapConfig { @@ -113,7 +120,7 @@ async fn run_guest_init( let mut guest_interface = guest_session.guest().await?; if !bootstrap.container.advanced.capabilities.is_empty() { guest_interface - .require_feature(boxlite_shared::constants::guest_features::LINUX_CAPABILITIES_V2) + .require_min_version(MIN_CAPABILITY_GUEST_VERSION) .await?; } guest_interface.init(bootstrap.guest).await?; diff --git a/src/boxlite/src/portal/interfaces/guest.rs b/src/boxlite/src/portal/interfaces/guest.rs index 7aa204f02..d0ab3f811 100644 --- a/src/boxlite/src/portal/interfaces/guest.rs +++ b/src/boxlite/src/portal/interfaces/guest.rs @@ -68,11 +68,15 @@ impl GuestInterface { Ok(()) } - /// Fail before initialization if the connected guest cannot honor a - /// security-sensitive request field. Older guests return no features. - pub async fn require_feature(&mut self, feature: &str) -> BoxliteResult<()> { + /// Fail before initialization if the guest predates a required field. + /// + /// A guest built before the field existed decodes it as an unknown proto + /// field and drops it, so the request would appear to succeed while the + /// setting was never applied. The guest reports its own build version; + /// gate on that instead of letting the request through. + pub async fn require_min_version(&mut self, minimum: GuestVersion) -> BoxliteResult<()> { let response = self.client.ping(PingRequest {}).await?.into_inner(); - ensure_guest_feature(&response.version, &response.features, feature) + ensure_guest_version(&response.version, minimum) } /// Shutdown the guest agent. @@ -100,13 +104,35 @@ impl GuestInterface { } } -fn ensure_guest_feature(version: &str, features: &[String], required: &str) -> BoxliteResult<()> { - if features.iter().any(|candidate| candidate == required) { +/// A guest agent version as `(major, minor, patch)`. +pub type GuestVersion = (u32, u32, u32); + +/// Parse the `major.minor.patch` core of a guest version. +/// +/// Any pre-release or build suffix is ignored, so a `0.9.8-rc1` guest counts +/// as 0.9.8: it is built from the tree that carries the field. Anything that +/// does not parse yields `None` and is rejected by the caller. +fn parse_guest_version(version: &str) -> Option { + let mut parts = version.split(['-', '+']).next()?.split('.'); + let mut next = || parts.next()?.parse::().ok(); + let parsed = (next()?, next()?, next()?); + parts.next().is_none().then_some(parsed) +} + +fn ensure_guest_version(version: &str, minimum: GuestVersion) -> BoxliteResult<()> { + let (major, minor, patch) = minimum; + let Some(reported) = parse_guest_version(version) else { + return Err(BoxliteError::Unsupported(format!( + "guest reported an unrecognized version '{version}'; {major}.{minor}.{patch} or newer is required" + ))); + }; + + if reported >= minimum { return Ok(()); } Err(BoxliteError::Unsupported(format!( - "guest {version} does not support required feature '{required}'; recreate the box with the current runtime" + "guest {version} is older than the required {major}.{minor}.{patch}; recreate the box with the current runtime" ))) } @@ -114,23 +140,38 @@ fn ensure_guest_feature(version: &str, features: &[String], required: &str) -> B mod tests { use super::*; + const MINIMUM: GuestVersion = (0, 9, 8); + #[test] - fn old_guest_without_features_is_rejected_for_required_policy() { - let error = ensure_guest_feature("0.9.6", &[], "linux-capabilities-v2") - .expect_err("an old guest must not silently ignore security policy"); + fn guest_older_than_the_minimum_is_rejected() { + for version in ["0.9.7", "0.8.9", "0.9.7-rc1"] { + let error = ensure_guest_version(version, MINIMUM) + .expect_err("an old guest must not silently drop the field"); - assert!(matches!(error, BoxliteError::Unsupported(_))); - assert!(error.to_string().contains("linux-capabilities-v2")); + assert!(matches!(error, BoxliteError::Unsupported(_))); + assert!( + error.to_string().contains(version) && error.to_string().contains("0.9.8"), + "error should name both versions: {error}" + ); + } } #[test] - fn advertised_guest_feature_is_accepted() { - ensure_guest_feature( - "0.9.7", - &["linux-capabilities-v2".to_string()], - "linux-capabilities-v2", - ) - .expect("current guest advertises the feature"); + fn guest_at_or_above_the_minimum_is_accepted() { + for version in ["0.9.8", "0.9.9", "0.10.0", "1.0.0", "0.9.8-rc1"] { + ensure_guest_version(version, MINIMUM) + .unwrap_or_else(|error| panic!("{version} should be accepted: {error}")); + } + } + + #[test] + fn unparseable_guest_version_is_rejected() { + for version in ["", "0.9", "0.9.8.1", "abc", "0.x.8"] { + let error = ensure_guest_version(version, MINIMUM) + .expect_err("an unreadable version must fail closed"); + + assert!(matches!(error, BoxliteError::Unsupported(_))); + } } } diff --git a/src/boxlite/tests/security_enforcement.rs b/src/boxlite/tests/security_enforcement.rs index 2bee0bf2e..5c2d91313 100644 --- a/src/boxlite/tests/security_enforcement.rs +++ b/src/boxlite/tests/security_enforcement.rs @@ -227,77 +227,6 @@ async fn capabilities_match_docker_defaults(bx: &LiteBox) { ); } -#[tokio::test(flavor = "multi_thread")] -async fn custom_capabilities_apply_to_init_and_non_tty_exec() { - const CAP_NET_RAW: u64 = 1 << 13; - const CAP_SYS_ADMIN: u64 = 1 << 21; - - let home = boxlite_test_utils::home::PerTestBoxHome::new(); - let runtime = BoxliteRuntime::new(BoxliteOptions { - home_dir: home.path.clone(), - image_registries: common::test_registries(), - }) - .expect("create runtime"); - - let bx = runtime - .create( - BoxOptions { - advanced: boxlite::AdvancedBoxOptions { - capabilities: boxlite::ContainerCapabilities { - add: vec!["SYS_ADMIN".into()], - drop: vec!["NET_RAW".into()], - }, - ..Default::default() - }, - rootfs: RootfsSpec::Image("alpine:latest".into()), - auto_delete: Some(0), - ..Default::default() - }, - None, - ) - .await - .expect("create box"); - bx.start().await.expect("start box"); - - let init_caps = exec_stdout( - &bx, - BoxCommand::new("sh").args(["-c", "grep '^CapEff:' /proc/1/status"]), - ) - .await; - assert_capability_change(&init_caps, CAP_SYS_ADMIN, CAP_NET_RAW, "PID 1"); - - let exec_caps = exec_stdout( - &bx, - BoxCommand::new("sh") - .args(["-c", "grep '^CapEff:' /proc/self/status"]) - .tty(false), - ) - .await; - assert_capability_change(&exec_caps, CAP_SYS_ADMIN, CAP_NET_RAW, "non-TTY exec"); - - bx.stop().await.expect("stop box"); - let _ = runtime.shutdown(Some(common::TEST_SHUTDOWN_TIMEOUT)).await; -} - -fn assert_capability_change(status: &str, added: u64, dropped: u64, process: &str) { - let cap_eff = status - .trim() - .strip_prefix("CapEff:\t") - .and_then(|hex| u64::from_str_radix(hex.trim(), 16).ok()) - .expect("CapEff should contain a hexadecimal capability mask"); - - assert_ne!( - cap_eff & added, - 0, - "CAP_SYS_ADMIN should be effective for {process}, CapEff=0x{cap_eff:x}" - ); - assert_eq!( - cap_eff & dropped, - 0, - "CAP_NET_RAW should not be effective for {process}, CapEff=0x{cap_eff:x}" - ); -} - // ============================================================================ // TEST: TSI isolation when network is disabled // ============================================================================ diff --git a/src/guest/src/service/guest.rs b/src/guest/src/service/guest.rs index a1c5ed5ad..72bb6739a 100644 --- a/src/guest/src/service/guest.rs +++ b/src/guest/src/service/guest.rs @@ -83,9 +83,6 @@ impl GuestService for GuestServer { debug!("Received ping request"); Ok(Response::new(PingResponse { version: env!("CARGO_PKG_VERSION").to_string(), - features: vec![ - boxlite_shared::constants::guest_features::LINUX_CAPABILITIES_V2.to_string(), - ], })) } diff --git a/src/shared/proto/boxlite/v1/service.proto b/src/shared/proto/boxlite/v1/service.proto index fb72d72dd..e728089ed 100644 --- a/src/shared/proto/boxlite/v1/service.proto +++ b/src/shared/proto/boxlite/v1/service.proto @@ -184,9 +184,6 @@ message PingRequest {} message PingResponse { string version = 1; // Guest agent version - // Optional capabilities used for host/guest rollout negotiation. Unknown - // entries are ignored; an older guest decodes as an empty list. - repeated string features = 2; } message ShutdownRequest {} diff --git a/src/shared/src/constants.rs b/src/shared/src/constants.rs index 9d7483d7c..435d407cb 100644 --- a/src/shared/src/constants.rs +++ b/src/shared/src/constants.rs @@ -40,12 +40,6 @@ pub mod executor { pub const CONTAINER_KEY: &str = "container"; } -/// Features advertised by the guest agent during Ping. -pub mod guest_features { - /// Guest understands Docker-style capability deltas on Container.Init. - pub const LINUX_CAPABILITIES_V2: &str = "linux-capabilities-v2"; -} - /// Virtiofs mount tags /// /// These tags identify shared filesystems mounted via virtiofs. From b41a6a71dccbb7600200a6a72a1a25bc793341ae Mon Sep 17 00:00:00 2001 From: tester Date: Tue, 28 Jul 2026 00:05:17 +0800 Subject: [PATCH 10/10] refactor: scope capability policy to the core runtime MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Drop every `apps/` change and ship the capability policy in the core runtime, guest, SDKs and CLI only. The control-plane side — the box `advanced` column and DTOs, the runner feature advertisement, the scheduling filter and the start/recover gates — lands separately once the core contract is settled. `boxlite serve` remains the server half of the REST contract and still advertises `linux_capabilities_enabled`, so a remote SDK negotiates against it as before. The hosted API does not advertise the flag, so a BoxLite client refuses to send it a policy. That gate lives on the client: the hosted API does not reject unknown properties, so a caller that bypasses the negotiation would have `advanced` dropped. --- apps/api-client-go/api/openapi.yaml | 9 -- .../api-client-go/model_runner_healthcheck.go | 38 -------- .../src/box/common/box-advanced-options.ts | 24 ----- .../src/box/constants/runner-features.spec.ts | 31 ------ apps/api/src/box/constants/runner-features.ts | 22 ----- .../src/box/controllers/runner.controller.ts | 1 - apps/api/src/box/dto/box.dto.spec.ts | 2 - apps/api/src/box/dto/create-box.dto.ts | 64 +----------- apps/api/src/box/dto/runner-health.dto.ts | 10 -- apps/api/src/box/entities/box.entity.ts | 7 -- apps/api/src/box/entities/runner.entity.ts | 6 -- .../box-actions/box-start.action.spec.ts | 93 +----------------- .../managers/box-actions/box-start.action.ts | 22 ----- .../src/box/runner-adapter/runnerAdapter.ts | 1 - .../runner-adapter/runnerAdapter.v0.spec.ts | 68 ------------- .../box/runner-adapter/runnerAdapter.v0.ts | 9 +- .../runner-adapter/runnerAdapter.v2.spec.ts | 82 ---------------- .../box/runner-adapter/runnerAdapter.v2.ts | 11 +-- apps/api/src/box/services/box.service.spec.ts | 62 +----------- apps/api/src/box/services/box.service.ts | 19 +--- apps/api/src/box/services/runner.service.ts | 18 +--- .../box/utils/capability-validation.util.ts | 63 ------------ .../boxlite-rest/boxlite-box.controller.ts | 1 - .../boxlite-config.controller.spec.ts | 15 --- .../boxlite-rest/boxlite-config.controller.ts | 1 - .../boxlite-rest/boxlite-rest-routing.spec.ts | 29 ------ .../boxlite-rest/dto/create-box.dto.spec.ts | 66 +------------ .../src/boxlite-rest/dto/create-box.dto.ts | 10 -- .../mappers/box-to-box.mapper.spec.ts | 26 ----- .../boxlite-rest/mappers/box-to-box.mapper.ts | 1 - ...000-add-box-capabilities-migration.spec.ts | 40 -------- ...00000000-add-box-capabilities-migration.ts | 30 ------ .../api-client/src/docs/RunnerHealthcheck.md | 2 - .../src/models/runner-healthcheck.ts | 4 - .../src/.openapi-generator/FILES | 4 - .../src/docs/AdvancedBoxOptionsDTO.md | 9 -- .../src/docs/ContainerCapabilitiesDTO.md | 10 -- .../src/docs/CreateBoxDTO.md | 1 - .../src/docs/RecoverBoxDTO.md | 1 - .../src/docs/RunnerInfoResponseDTO.md | 2 - .../src/models/advanced-box-options-dto.ts | 15 --- .../src/models/container-capabilities-dto.ts | 14 --- .../src/models/create-box-dto.ts | 4 - .../runner-api-client/src/models/index.ts | 2 - .../src/models/recover-box-dto.ts | 4 - .../src/models/runner-info-response-dto.ts | 1 - apps/runner/internal/features.go | 8 -- apps/runner/pkg/api/controllers/box.go | 68 ++++++------- apps/runner/pkg/api/controllers/info.go | 1 - apps/runner/pkg/api/docs/docs.go | 42 -------- apps/runner/pkg/api/docs/swagger.json | 40 -------- apps/runner/pkg/api/docs/swagger.yaml | 28 ------ apps/runner/pkg/api/dto/box.go | 17 ---- .../pkg/api/dto/box_capabilities_test.go | 60 ------------ apps/runner/pkg/api/dto/info.go | 1 - apps/runner/pkg/boxlite/client.go | 15 --- apps/runner/pkg/boxlite/stubs.go | 1 - apps/runner/pkg/common/errors.go | 14 --- apps/runner/pkg/common/errors_test.go | 40 -------- .../v2/executor/box_capabilities_test.go | 97 ------------------- .../pkg/runner/v2/healthcheck/healthcheck.go | 1 - docs/architecture/container-capabilities.md | 26 ++--- docs/reference/README.md | 5 +- 63 files changed, 65 insertions(+), 1353 deletions(-) delete mode 100644 apps/api/src/box/common/box-advanced-options.ts delete mode 100644 apps/api/src/box/constants/runner-features.spec.ts delete mode 100644 apps/api/src/box/constants/runner-features.ts delete mode 100644 apps/api/src/box/runner-adapter/runnerAdapter.v0.spec.ts delete mode 100644 apps/api/src/box/runner-adapter/runnerAdapter.v2.spec.ts delete mode 100644 apps/api/src/box/utils/capability-validation.util.ts delete mode 100644 apps/api/src/boxlite-rest/boxlite-config.controller.spec.ts delete mode 100644 apps/api/src/migrations/pre-deploy/1785000000000-add-box-capabilities-migration.spec.ts delete mode 100644 apps/api/src/migrations/pre-deploy/1785000000000-add-box-capabilities-migration.ts delete mode 100644 apps/libs/runner-api-client/src/docs/AdvancedBoxOptionsDTO.md delete mode 100644 apps/libs/runner-api-client/src/docs/ContainerCapabilitiesDTO.md delete mode 100644 apps/libs/runner-api-client/src/models/advanced-box-options-dto.ts delete mode 100644 apps/libs/runner-api-client/src/models/container-capabilities-dto.ts delete mode 100644 apps/runner/internal/features.go delete mode 100644 apps/runner/pkg/api/dto/box_capabilities_test.go delete mode 100644 apps/runner/pkg/common/errors_test.go delete mode 100644 apps/runner/pkg/runner/v2/executor/box_capabilities_test.go diff --git a/apps/api-client-go/api/openapi.yaml b/apps/api-client-go/api/openapi.yaml index ac146a67c..e18d88137 100644 --- a/apps/api-client-go/api/openapi.yaml +++ b/apps/api-client-go/api/openapi.yaml @@ -7249,8 +7249,6 @@ components: proxyUrl: http://proxy.boxlite.example.com:8080 apiUrl: http://api.boxlite.example.com:8080 appVersion: v0.0.0-dev - features: - - linux-capabilities-v2 properties: metrics: allOf: @@ -7277,13 +7275,6 @@ components: description: Runner app version example: v0.0.0-dev type: string - features: - description: Optional runner features used for rollout negotiation - example: - - linux-capabilities-v2 - items: - type: string - type: array required: - appVersion type: object diff --git a/apps/api-client-go/model_runner_healthcheck.go b/apps/api-client-go/model_runner_healthcheck.go index 1bfd5b675..b2d8fa500 100644 --- a/apps/api-client-go/model_runner_healthcheck.go +++ b/apps/api-client-go/model_runner_healthcheck.go @@ -33,8 +33,6 @@ type RunnerHealthcheck struct { ApiUrl *string `json:"apiUrl,omitempty"` // Runner app version AppVersion string `json:"appVersion"` - // Optional runner features used for rollout negotiation - Features []string `json:"features,omitempty"` AdditionalProperties map[string]interface{} } @@ -242,38 +240,6 @@ func (o *RunnerHealthcheck) SetAppVersion(v string) { o.AppVersion = v } -// GetFeatures returns the Features field value if set, zero value otherwise. -func (o *RunnerHealthcheck) GetFeatures() []string { - if o == nil || IsNil(o.Features) { - var ret []string - return ret - } - return o.Features -} - -// GetFeaturesOk returns a tuple with the Features field value if set, nil otherwise -// and a boolean to check if the value has been set. -func (o *RunnerHealthcheck) GetFeaturesOk() ([]string, bool) { - if o == nil || IsNil(o.Features) { - return nil, false - } - return o.Features, true -} - -// HasFeatures returns a boolean if a field has been set. -func (o *RunnerHealthcheck) HasFeatures() bool { - if o != nil && !IsNil(o.Features) { - return true - } - - return false -} - -// SetFeatures gets a reference to the given []string and assigns it to the Features field. -func (o *RunnerHealthcheck) SetFeatures(v []string) { - o.Features = v -} - func (o RunnerHealthcheck) MarshalJSON() ([]byte, error) { toSerialize,err := o.ToMap() if err != nil { @@ -300,9 +266,6 @@ func (o RunnerHealthcheck) ToMap() (map[string]interface{}, error) { toSerialize["apiUrl"] = o.ApiUrl } toSerialize["appVersion"] = o.AppVersion - if !IsNil(o.Features) { - toSerialize["features"] = o.Features - } for key, value := range o.AdditionalProperties { toSerialize[key] = value @@ -352,7 +315,6 @@ func (o *RunnerHealthcheck) UnmarshalJSON(data []byte) (err error) { delete(additionalProperties, "proxyUrl") delete(additionalProperties, "apiUrl") delete(additionalProperties, "appVersion") - delete(additionalProperties, "features") o.AdditionalProperties = additionalProperties } diff --git a/apps/api/src/box/common/box-advanced-options.ts b/apps/api/src/box/common/box-advanced-options.ts deleted file mode 100644 index 6a938d7a6..000000000 --- a/apps/api/src/box/common/box-advanced-options.ts +++ /dev/null @@ -1,24 +0,0 @@ -export interface LinuxCapabilities { - add: string[] - drop: string[] -} - -export interface BoxAdvancedOptions { - capabilities: LinuxCapabilities -} - -export function normalizeBoxAdvancedOptions( - advanced?: { - capabilities?: { - add?: readonly string[] | null - drop?: readonly string[] | null - } | null - } | null, -): BoxAdvancedOptions { - return { - capabilities: { - add: [...(advanced?.capabilities?.add ?? [])], - drop: [...(advanced?.capabilities?.drop ?? [])], - }, - } -} diff --git a/apps/api/src/box/constants/runner-features.spec.ts b/apps/api/src/box/constants/runner-features.spec.ts deleted file mode 100644 index ab0fc067f..000000000 --- a/apps/api/src/box/constants/runner-features.spec.ts +++ /dev/null @@ -1,31 +0,0 @@ -import { RUNNER_FEATURES, requiredRunnerFeaturesForCapabilities, runnerSupportsFeatures } from './runner-features' - -describe('runner feature negotiation', () => { - it('requires every requested feature and treats old runners as unsupported', () => { - expect(runnerSupportsFeatures(undefined, [RUNNER_FEATURES.LINUX_CAPABILITIES_V2])).toBe(false) - expect(runnerSupportsFeatures([], [RUNNER_FEATURES.LINUX_CAPABILITIES_V2])).toBe(false) - expect(runnerSupportsFeatures(['other'], [RUNNER_FEATURES.LINUX_CAPABILITIES_V2])).toBe(false) - expect( - runnerSupportsFeatures( - [RUNNER_FEATURES.LINUX_CAPABILITIES_V2], - [RUNNER_FEATURES.LINUX_CAPABILITIES_V2], - ), - ).toBe(true) - }) - - it('does not constrain ordinary boxes', () => { - expect(runnerSupportsFeatures(undefined, undefined)).toBe(true) - expect(runnerSupportsFeatures(undefined, [])).toBe(true) - }) - - it('derives the feature requirement from either capability list', () => { - expect(requiredRunnerFeaturesForCapabilities(undefined)).toEqual([]) - expect(requiredRunnerFeaturesForCapabilities({ add: [], drop: [] })).toEqual([]) - expect(requiredRunnerFeaturesForCapabilities({ add: ['SYS_PTRACE'], drop: [] })).toEqual([ - RUNNER_FEATURES.LINUX_CAPABILITIES_V2, - ]) - expect(requiredRunnerFeaturesForCapabilities({ add: [], drop: ['NET_RAW'] })).toEqual([ - RUNNER_FEATURES.LINUX_CAPABILITIES_V2, - ]) - }) -}) diff --git a/apps/api/src/box/constants/runner-features.ts b/apps/api/src/box/constants/runner-features.ts deleted file mode 100644 index 244a36c4e..000000000 --- a/apps/api/src/box/constants/runner-features.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { LinuxCapabilities } from '../common/box-advanced-options' - -export const RUNNER_FEATURES = { - LINUX_CAPABILITIES_V2: 'linux-capabilities-v2', -} as const - -export function requiredRunnerFeaturesForCapabilities( - capabilities: Pick | null | undefined, -): string[] { - return capabilities?.add.length || capabilities?.drop.length ? [RUNNER_FEATURES.LINUX_CAPABILITIES_V2] : [] -} - -export function runnerSupportsFeatures( - advertised: readonly string[] | null | undefined, - required: readonly string[] | null | undefined, -): boolean { - if (!required?.length) { - return true - } - const available = new Set(advertised ?? []) - return required.every((feature) => available.has(feature)) -} diff --git a/apps/api/src/box/controllers/runner.controller.ts b/apps/api/src/box/controllers/runner.controller.ts index 0e07f3b8b..95854fdc3 100644 --- a/apps/api/src/box/controllers/runner.controller.ts +++ b/apps/api/src/box/controllers/runner.controller.ts @@ -338,7 +338,6 @@ export class RunnerController { healthcheck.serviceHealth, healthcheck.metrics, healthcheck.appVersion, - healthcheck.features, ) } } diff --git a/apps/api/src/box/dto/box.dto.spec.ts b/apps/api/src/box/dto/box.dto.spec.ts index 737fd844c..e6c1f2916 100644 --- a/apps/api/src/box/dto/box.dto.spec.ts +++ b/apps/api/src/box/dto/box.dto.spec.ts @@ -12,12 +12,10 @@ describe('BoxDto public identity', () => { const box = new Box('us', 'data-loader') box.organizationId = '057963b2-60ca-4356-81fc-11503e15f249' box.osUser = 'boxlite' - box.advanced = { capabilities: { add: ['SYS_ADMIN'], drop: ['NET_RAW'] } } const dto = BoxDto.fromBox(box, 'https://proxy.boxlite.dev/toolbox') expect(dto.id).toBe(box.id) expect((dto as any).boxId).toBeUndefined() - expect((dto as any).advanced).toBeUndefined() }) }) diff --git a/apps/api/src/box/dto/create-box.dto.ts b/apps/api/src/box/dto/create-box.dto.ts index bb6fd0484..be0d7ea93 100644 --- a/apps/api/src/box/dto/create-box.dto.ts +++ b/apps/api/src/box/dto/create-box.dto.ts @@ -4,64 +4,10 @@ * SPDX-License-Identifier: AGPL-3.0 */ -import { - IsEnum, - IsObject, - IsOptional, - IsString, - IsNumber, - IsBoolean, - IsArray, - IsInt, - Min, - Validate, - ValidateIf, - ValidateNested, -} from 'class-validator' -import { Type } from 'class-transformer' +import { IsEnum, IsObject, IsOptional, IsString, IsNumber, IsBoolean, IsArray, IsInt, Min } from 'class-validator' import { ApiPropertyOptional, ApiSchema } from '@nestjs/swagger' import { BoxClass } from '../enums/box-class.enum' import { BoxVolume } from './box.dto' -import { - HasNoUnknownCapabilityFieldsConstraint, - IsLinuxCapabilityNameConstraint, -} from '../utils/capability-validation.util' - -@ApiSchema({ name: 'CreateLinuxCapabilities' }) -export class CreateLinuxCapabilitiesDto { - @ApiPropertyOptional({ - description: 'Linux capabilities to add to the default container capability set', - type: [String], - example: ['SYS_ADMIN'], - }) - @ValidateIf((_object, value) => value !== undefined) - @IsArray() - @IsString({ each: true }) - @Validate(IsLinuxCapabilityNameConstraint, { each: true }) - add?: string[] - - @ApiPropertyOptional({ - description: 'Linux capabilities to remove from the container capability set', - type: [String], - example: ['NET_RAW'], - }) - @ValidateIf((_object, value) => value !== undefined) - @IsArray() - @IsString({ each: true }) - @Validate(IsLinuxCapabilityNameConstraint, { each: true }) - drop?: string[] -} - -@ApiSchema({ name: 'CreateBoxAdvancedOptions' }) -export class CreateBoxAdvancedOptionsDto { - @ApiPropertyOptional({ type: CreateLinuxCapabilitiesDto }) - @ValidateIf((_object, value) => value !== undefined) - @IsObject() - @ValidateNested() - @Validate(HasNoUnknownCapabilityFieldsConstraint, [['add', 'drop']]) - @Type(() => CreateLinuxCapabilitiesDto) - capabilities?: CreateLinuxCapabilitiesDto -} @ApiSchema({ name: 'CreateBox' }) export class CreateBoxDto { @@ -100,14 +46,6 @@ export class CreateBoxDto { @IsObject() env?: { [key: string]: string } - @ApiPropertyOptional({ type: CreateBoxAdvancedOptionsDto }) - @ValidateIf((_object, value) => value !== undefined) - @IsObject() - @ValidateNested() - @Validate(HasNoUnknownCapabilityFieldsConstraint, [['capabilities']]) - @Type(() => CreateBoxAdvancedOptionsDto) - advanced?: CreateBoxAdvancedOptionsDto - @ApiPropertyOptional({ description: 'Labels for the box', type: 'object', diff --git a/apps/api/src/box/dto/runner-health.dto.ts b/apps/api/src/box/dto/runner-health.dto.ts index ca055fd98..e3347241a 100644 --- a/apps/api/src/box/dto/runner-health.dto.ts +++ b/apps/api/src/box/dto/runner-health.dto.ts @@ -159,14 +159,4 @@ export class RunnerHealthcheckDto { }) @IsString() appVersion: string - - @ApiPropertyOptional({ - description: 'Optional runner features used for rollout negotiation', - type: [String], - example: ['linux-capabilities-v2'], - }) - @IsOptional() - @IsArray() - @IsString({ each: true }) - features?: string[] } diff --git a/apps/api/src/box/entities/box.entity.ts b/apps/api/src/box/entities/box.entity.ts index f4377e0b2..a4eb59fbf 100644 --- a/apps/api/src/box/entities/box.entity.ts +++ b/apps/api/src/box/entities/box.entity.ts @@ -17,7 +17,6 @@ import { DEFAULT_AUTO_PAUSE_SECONDS, DEFAULT_AUTO_RESUME, } from '../constants/box-lifecycle.constants' -import { BoxAdvancedOptions, normalizeBoxAdvancedOptions } from '../common/box-advanced-options' @Entity('box') @Unique(['organizationId', 'name']) @@ -109,12 +108,6 @@ export class Box { }) env: { [key: string]: string } = {} - @Column({ - type: 'jsonb', - default: () => `'${JSON.stringify(normalizeBoxAdvancedOptions())}'::jsonb`, - }) - advanced: BoxAdvancedOptions = normalizeBoxAdvancedOptions() - @Column({ default: false, type: 'boolean' }) public = false diff --git a/apps/api/src/box/entities/runner.entity.ts b/apps/api/src/box/entities/runner.entity.ts index afe757824..e1ba1253e 100644 --- a/apps/api/src/box/entities/runner.entity.ts +++ b/apps/api/src/box/entities/runner.entity.ts @@ -145,12 +145,6 @@ export class Runner { }) apiVersion: string - @Column({ - type: 'jsonb', - default: [], - }) - features: string[] = [] - @Column({ nullable: true, type: 'timestamp with time zone', diff --git a/apps/api/src/box/managers/box-actions/box-start.action.spec.ts b/apps/api/src/box/managers/box-actions/box-start.action.spec.ts index d0d74abf3..e0b112820 100644 --- a/apps/api/src/box/managers/box-actions/box-start.action.spec.ts +++ b/apps/api/src/box/managers/box-actions/box-start.action.spec.ts @@ -10,7 +10,7 @@ jest.mock('uuid', () => ({ })) import { BoxStartAction } from './box-start.action' -import { BoxAction, DONT_SYNC_AGAIN, SYNC_AGAIN } from './box.action' +import { BoxAction, SYNC_AGAIN } from './box.action' import { Box } from '../../entities/box.entity' import { Runner } from '../../entities/runner.entity' import { BoxState } from '../../enums/box-state.enum' @@ -126,51 +126,6 @@ describe('BoxStartAction.handleRunnerBoxStoppedStateOnDesiredStateStart', () => expect(runnerAdapterFactory.create).not.toHaveBeenCalled() expect(updatedFields.some((u) => u.state === BoxState.ERROR)).toBe(true) }) - - it('rejects a capability policy when its assigned runner does not advertise support', async () => { - const runnerId = 'runner-without-capabilities' - const box = new Box('region-1', 'stopped-capability-box') - box.runnerId = runnerId - box.state = BoxState.STOPPED - box.desiredState = BoxDesiredState.STARTED - box.pending = true - box.advanced = { capabilities: { add: [], drop: ['NET_RAW'] } } - - const runner = { id: runnerId, state: RunnerState.READY, features: [] } as Runner - const runnerService = { findOneOrFail: jest.fn(async () => runner) } - const runnerAdapterFactory = { create: jest.fn() } - const lockCode = new LockCode('lock-capability-restart') - const updatedFields: Partial[] = [] - const boxRepository = { - update: jest.fn(async (_id: string, opts: { updateData: Partial }) => { - updatedFields.push(opts.updateData) - return box - }), - } - const redisLockProvider = { getCode: jest.fn(async () => lockCode) } - const organizationService = { findOne: jest.fn(async () => ({ boxMetadata: {} })) } - - const action = new BoxStartAction( - runnerService as any, - runnerAdapterFactory as any, - boxRepository as any, - organizationService as any, - {} as any, - redisLockProvider as any, - {} as any, - ) - - const result = await (action as BoxAction).run(box, lockCode) - - expect(result).toBe(DONT_SYNC_AGAIN) - expect(runnerAdapterFactory.create).not.toHaveBeenCalled() - expect(updatedFields).toContainEqual( - expect.objectContaining({ - state: BoxState.ERROR, - errorReason: expect.stringContaining('linux-capabilities-v2'), - }), - ) - }) }) describe('BoxStartAction.handleRunnerBoxUnknownStateOnDesiredStateStart', () => { @@ -259,50 +214,4 @@ describe('BoxStartAction.handleRunnerBoxUnknownStateOnDesiredStateStart', () => expect(createBox).not.toHaveBeenCalled() expect(updatedFields.some((u) => u.state === BoxState.ERROR)).toBe(true) }) - - it('rejects a capability policy when its assigned runner does not advertise support', async () => { - const runnerId = 'runner-without-capabilities' - const box = new Box('region-1', 'new-capability-box') - box.runnerId = runnerId - box.image = 'boxlite/base' - box.state = BoxState.UNKNOWN - box.desiredState = BoxDesiredState.STARTED - box.pending = true - box.advanced = { capabilities: { add: ['SYS_PTRACE'], drop: [] } } - - const runner = { id: runnerId, state: RunnerState.READY, features: [] } as Runner - const runnerService = { findOneOrFail: jest.fn(async () => runner) } - const runnerAdapterFactory = { create: jest.fn() } - const lockCode = new LockCode('lock-capability-create') - const updatedFields: Partial[] = [] - const boxRepository = { - update: jest.fn(async (_id: string, opts: { updateData: Partial }) => { - updatedFields.push(opts.updateData) - return box - }), - } - const redisLockProvider = { getCode: jest.fn(async () => lockCode) } - const organizationService = { findOne: jest.fn(async () => ({ boxMetadata: {} })) } - - const action = new BoxStartAction( - runnerService as any, - runnerAdapterFactory as any, - boxRepository as any, - organizationService as any, - {} as any, - redisLockProvider as any, - {} as any, - ) - - const result = await (action as BoxAction).run(box, lockCode) - - expect(result).toBe(DONT_SYNC_AGAIN) - expect(runnerAdapterFactory.create).not.toHaveBeenCalled() - expect(updatedFields).toContainEqual( - expect.objectContaining({ - state: BoxState.ERROR, - errorReason: expect.stringContaining('linux-capabilities-v2'), - }), - ) - }) }) diff --git a/apps/api/src/box/managers/box-actions/box-start.action.ts b/apps/api/src/box/managers/box-actions/box-start.action.ts index a6119fd30..414de0c97 100644 --- a/apps/api/src/box/managers/box-actions/box-start.action.ts +++ b/apps/api/src/box/managers/box-actions/box-start.action.ts @@ -17,8 +17,6 @@ import { TypedConfigService } from '../../../config/typed-config.service' import { LockCode, RedisLockProvider } from '../../common/redis-lock.provider' import { WithSpan } from '../../../common/decorators/otel.decorator' import { BoxActivityService } from '../../services/box-activity.service' -import { Runner } from '../../entities/runner.entity' -import { requiredRunnerFeaturesForCapabilities, runnerSupportsFeatures } from '../../constants/runner-features' @Injectable() export class BoxStartAction extends BoxAction { @@ -64,10 +62,6 @@ export class BoxStartAction extends BoxAction { return DONT_SYNC_AGAIN } - if (!(await this.ensureRunnerSupportsCapabilities(box, runner, lockCode))) { - return DONT_SYNC_AGAIN - } - if (!box.image) { await this.updateBoxState(box, BoxState.ERROR, lockCode, undefined, 'Box has no image to create from') return DONT_SYNC_AGAIN @@ -104,10 +98,6 @@ export class BoxStartAction extends BoxAction { return DONT_SYNC_AGAIN } - if (!(await this.ensureRunnerSupportsCapabilities(box, runner, lockCode))) { - return DONT_SYNC_AGAIN - } - const runnerAdapter = await this.runnerAdapterFactory.create(runner) const metadata: { [key: string]: string } = { ...organization?.boxMetadata } @@ -201,18 +191,6 @@ export class BoxStartAction extends BoxAction { return SYNC_AGAIN } - private async ensureRunnerSupportsCapabilities(box: Box, runner: Runner, lockCode: LockCode): Promise { - const requiredFeatures = requiredRunnerFeaturesForCapabilities(box.advanced.capabilities) - if (runnerSupportsFeatures(runner.features, requiredFeatures)) { - return true - } - - const errorReason = `Runner ${runner.id} does not support required feature: ${requiredFeatures.join(', ')}` - this.logger.error(`Cannot start box ${box.id}: ${errorReason}`) - await this.updateBoxState(box, BoxState.ERROR, lockCode, undefined, errorReason) - return false - } - private async checkTimeoutError(box: Box, timeoutMinutes: number, errorReason: string): Promise { const lastActivityAt = await this.boxActivityService.getLastActivityAt(box.id) if (lastActivityAt && lastActivityAt.getTime() < Date.now() - 1000 * 60 * timeoutMinutes) { diff --git a/apps/api/src/box/runner-adapter/runnerAdapter.ts b/apps/api/src/box/runner-adapter/runnerAdapter.ts index dff6a2e73..1d1d023f0 100644 --- a/apps/api/src/box/runner-adapter/runnerAdapter.ts +++ b/apps/api/src/box/runner-adapter/runnerAdapter.ts @@ -32,7 +32,6 @@ export interface RunnerInfo { serviceHealth?: RunnerServiceInfo[] metrics?: RunnerMetrics appVersion?: string - features?: string[] } export interface StartBoxResponse { diff --git a/apps/api/src/box/runner-adapter/runnerAdapter.v0.spec.ts b/apps/api/src/box/runner-adapter/runnerAdapter.v0.spec.ts deleted file mode 100644 index ff45a3602..000000000 --- a/apps/api/src/box/runner-adapter/runnerAdapter.v0.spec.ts +++ /dev/null @@ -1,68 +0,0 @@ -/* - * Copyright 2025 BoxLite AI - * SPDX-License-Identifier: AGPL-3.0 - */ - -import { Box } from '../entities/box.entity' -import { RunnerAdapterV0 } from './runnerAdapter.v0' - -describe('RunnerAdapterV0 capability propagation', () => { - function makeAdapter() { - const boxApiClient = { - create: jest.fn().mockResolvedValue({ data: {} }), - recover: jest.fn().mockResolvedValue({ data: {} }), - } - const adapter = new RunnerAdapterV0() - Object.assign(adapter as any, { boxApiClient }) - return { adapter, boxApiClient } - } - - function customCapabilityBox() { - const box = new Box('region-1', 'cap-box') - Object.assign(box, { - image: 'alpine:latest', - organizationId: 'org-1', - osUser: 'boxlite', - advanced: { capabilities: { add: ['SYS_ADMIN'], drop: ['NET_RAW'] } }, - }) - return box - } - - it('forwards the capability policy on create', async () => { - const { adapter, boxApiClient } = makeAdapter() - - await adapter.createBox(customCapabilityBox()) - - expect(boxApiClient.create).toHaveBeenCalledWith( - expect.objectContaining({ - advanced: { capabilities: { add: ['SYS_ADMIN'], drop: ['NET_RAW'] } }, - }), - ) - }) - - it('forwards the capability policy on recovery', async () => { - const { adapter, boxApiClient } = makeAdapter() - const box = customCapabilityBox() - - await adapter.recoverBox(box) - - expect(boxApiClient.recover).toHaveBeenCalledWith( - box.id, - expect.objectContaining({ - advanced: { capabilities: { add: ['SYS_ADMIN'], drop: ['NET_RAW'] } }, - }), - ) - }) - - it('sends an empty policy for a box without capability overrides', async () => { - const { adapter, boxApiClient } = makeAdapter() - const box = customCapabilityBox() - box.advanced = { capabilities: { add: [], drop: [] } } - - await adapter.createBox(box) - - expect(boxApiClient.create).toHaveBeenCalledWith( - expect.objectContaining({ advanced: { capabilities: { add: [], drop: [] } } }), - ) - }) -}) diff --git a/apps/api/src/box/runner-adapter/runnerAdapter.v0.ts b/apps/api/src/box/runner-adapter/runnerAdapter.v0.ts index 5552cface..dd6e55585 100644 --- a/apps/api/src/box/runner-adapter/runnerAdapter.v0.ts +++ b/apps/api/src/box/runner-adapter/runnerAdapter.v0.ts @@ -19,7 +19,6 @@ import { UpdateNetworkSettingsDTO, RecoverBoxDTO, } from '@boxlite-ai/runner-api-client' -import { normalizeBoxAdvancedOptions } from '../common/box-advanced-options' import { Box } from '../entities/box.entity' import { BoxState } from '../enums/box-state.enum' import { RunnerApiError } from '../errors/runner-api-error' @@ -239,7 +238,6 @@ export class RunnerAdapterV0 implements RunnerAdapter { serviceHealth: response.data.serviceHealth, metrics: response.data.metrics, appVersion: response.data.appVersion, - features: response.data.features, } } @@ -252,7 +250,7 @@ export class RunnerAdapterV0 implements RunnerAdapter { } async createBox(box: Box, metadata?: { [key: string]: string }): Promise { - const createBoxDTO = { + const response = await this.boxApiClient.create({ id: box.id, image: box.image ?? '', osUser: box.osUser, @@ -267,9 +265,7 @@ export class RunnerAdapterV0 implements RunnerAdapter { authToken: box.authToken, organizationId: box.organizationId, regionId: box.region, - advanced: normalizeBoxAdvancedOptions(box.advanced), - } - const response = await this.boxApiClient.create(createBoxDTO) + }) if (!response?.data?.daemonVersion) { return undefined @@ -335,7 +331,6 @@ export class RunnerAdapterV0 implements RunnerAdapter { networkBlockAll: box.networkBlockAll, networkAllowList: box.networkAllowList, errorReason: box.errorReason, - advanced: normalizeBoxAdvancedOptions(box.advanced), } await this.boxApiClient.recover(box.id, recoverBoxDTO) } diff --git a/apps/api/src/box/runner-adapter/runnerAdapter.v2.spec.ts b/apps/api/src/box/runner-adapter/runnerAdapter.v2.spec.ts deleted file mode 100644 index 4a9e73420..000000000 --- a/apps/api/src/box/runner-adapter/runnerAdapter.v2.spec.ts +++ /dev/null @@ -1,82 +0,0 @@ -/* - * Copyright 2025 BoxLite AI - * SPDX-License-Identifier: AGPL-3.0 - */ - -import { Box } from '../entities/box.entity' -import { JobType } from '../enums/job-type.enum' -import { ResourceType } from '../enums/resource-type.enum' -import { RunnerAdapterV2 } from './runnerAdapter.v2' - -describe('RunnerAdapterV2 capability propagation', () => { - function makeAdapter() { - const jobService = { createJob: jest.fn().mockResolvedValue(undefined) } - const adapter = new RunnerAdapterV2({} as any, {} as any, jobService as any) - adapter.init({ id: 'runner-1' } as any) - return { adapter, jobService } - } - - function customCapabilityBox() { - const box = new Box('region-1', 'cap-box') - Object.assign(box as any, { - image: 'alpine:latest', - organizationId: 'org-1', - osUser: 'boxlite', - advanced: { capabilities: { add: ['SYS_ADMIN'], drop: ['NET_RAW'] } }, - }) - return box - } - - it('forwards the capability policy on the create job', async () => { - const { adapter, jobService } = makeAdapter() - const box = customCapabilityBox() - - await adapter.createBox(box) - - expect(jobService.createJob).toHaveBeenCalledWith( - null, - JobType.CREATE_BOX, - 'runner-1', - ResourceType.BOX, - box.id, - expect.objectContaining({ - advanced: { capabilities: { add: ['SYS_ADMIN'], drop: ['NET_RAW'] } }, - }), - ) - }) - - it('forwards the capability policy on the recovery job', async () => { - const { adapter, jobService } = makeAdapter() - const box = customCapabilityBox() - - await adapter.recoverBox(box) - - expect(jobService.createJob).toHaveBeenCalledWith( - null, - JobType.RECOVER_BOX, - 'runner-1', - ResourceType.BOX, - box.id, - expect.objectContaining({ - advanced: { capabilities: { add: ['SYS_ADMIN'], drop: ['NET_RAW'] } }, - }), - ) - }) - - it('sends an empty policy for a box without capability overrides', async () => { - const { adapter, jobService } = makeAdapter() - const box = customCapabilityBox() - box.advanced = { capabilities: { add: [], drop: [] } } - - await adapter.createBox(box) - - expect(jobService.createJob).toHaveBeenCalledWith( - null, - JobType.CREATE_BOX, - 'runner-1', - ResourceType.BOX, - box.id, - expect.objectContaining({ advanced: { capabilities: { add: [], drop: [] } } }), - ) - }) -}) diff --git a/apps/api/src/box/runner-adapter/runnerAdapter.v2.ts b/apps/api/src/box/runner-adapter/runnerAdapter.v2.ts index 4dfb25b3d..ba4eee098 100644 --- a/apps/api/src/box/runner-adapter/runnerAdapter.v2.ts +++ b/apps/api/src/box/runner-adapter/runnerAdapter.v2.ts @@ -13,7 +13,6 @@ import { Box } from '../entities/box.entity' import { Job } from '../entities/job.entity' import { BoxState } from '../enums/box-state.enum' import { JobType } from '../enums/job-type.enum' -import { normalizeBoxAdvancedOptions } from '../common/box-advanced-options' import { JobStatus } from '../enums/job-status.enum' import { ResourceType } from '../enums/resource-type.enum' import { JobService } from '../services/job.service' @@ -142,10 +141,7 @@ export class RunnerAdapterV2 implements RunnerAdapter { regionId: box.region, } - await this.jobService.createJob(null, JobType.CREATE_BOX, this.runner.id, ResourceType.BOX, box.id, { - ...payload, - advanced: normalizeBoxAdvancedOptions(box.advanced), - }) + await this.jobService.createJob(null, JobType.CREATE_BOX, this.runner.id, ResourceType.BOX, box.id, payload) this.logger.debug(`Created CREATE_BOX job for box ${box.id} on runner ${this.runner.id}`) @@ -200,10 +196,7 @@ export class RunnerAdapterV2 implements RunnerAdapter { networkAllowList: box.networkAllowList, errorReason: box.errorReason, } - await this.jobService.createJob(null, JobType.RECOVER_BOX, this.runner.id, ResourceType.BOX, box.id, { - ...recoverBoxDTO, - advanced: normalizeBoxAdvancedOptions(box.advanced), - }) + await this.jobService.createJob(null, JobType.RECOVER_BOX, this.runner.id, ResourceType.BOX, box.id, recoverBoxDTO) this.logger.debug(`Created RECOVER_BOX job for box ${box.id} on runner ${this.runner.id}`) } diff --git a/apps/api/src/box/services/box.service.spec.ts b/apps/api/src/box/services/box.service.spec.ts index b72611524..6758ed258 100644 --- a/apps/api/src/box/services/box.service.spec.ts +++ b/apps/api/src/box/services/box.service.spec.ts @@ -290,9 +290,6 @@ describe('BoxService network tunnel URLs', () => { describe('BoxService public defaults', () => { function makeCreateService() { const boxRepository = { insert: jest.fn(async (box: any) => box) } as any - const runnerService = { - getRandomAvailableRunner: jest.fn().mockResolvedValue({ id: 'runner-1', features: ['linux-capabilities-v2'] }), - } const service = Object.create(BoxService.prototype) as BoxService Object.assign(service as any, { getValidatedOrDefaultRegion: jest.fn().mockResolvedValue({ id: 'region-1' }), @@ -303,12 +300,12 @@ describe('BoxService public defaults', () => { rollbackPendingUsage: jest.fn().mockResolvedValue(undefined), }, redis: { exists: jest.fn().mockResolvedValue(1) }, - runnerService, + runnerService: { getRandomAvailableRunner: jest.fn().mockResolvedValue({ id: 'runner-1' }) }, boxRepository, eventEmitter: { emitAsync: jest.fn().mockResolvedValue(undefined) }, toBoxDto: jest.fn((box) => box), }) - return { service, boxRepository, runnerService } + return { service, boxRepository } } it.each([ @@ -322,61 +319,6 @@ describe('BoxService public defaults', () => { expect(boxRepository.insert).toHaveBeenCalledWith(expect.objectContaining({ public: expectedPublic })) }) - it('persists capability overrides on a fresh box', async () => { - const { service, boxRepository, runnerService } = makeCreateService() - - await service.create( - { - name: 'cap-box', - advanced: { capabilities: { add: ['SYS_ADMIN'], drop: ['NET_RAW'] } }, - } as any, - { id: 'org-1' } as any, - ) - - expect(boxRepository.insert).toHaveBeenCalledWith( - expect.objectContaining({ - advanced: { capabilities: { add: ['SYS_ADMIN'], drop: ['NET_RAW'] } }, - }), - ) - expect(runnerService.getRandomAvailableRunner).toHaveBeenCalledWith( - expect.objectContaining({ requiredFeatures: ['linux-capabilities-v2'] }), - ) - }) - - it('does not assign a default-capability warm-pool box to a custom-capability request', async () => { - const fetchWarmPoolBox = jest.fn().mockResolvedValue(null) - const boxRepository = { insert: jest.fn(async (box: any) => box) } as any - const service = Object.create(BoxService.prototype) as BoxService - Object.assign(service as any, { - getValidatedOrDefaultRegion: jest.fn().mockResolvedValue({ id: 'region-1' }), - getValidatedOrDefaultClass: jest.fn().mockReturnValue('small'), - organizationService: { assertOrganizationIsNotSuspended: jest.fn() }, - organizationUsageService: { - validateOrganizationQuotas: jest.fn().mockResolvedValue({ cpu: 0, memory: 0, disk: 0, gpu: 0, count: 0 }), - rollbackPendingUsage: jest.fn().mockResolvedValue(undefined), - }, - redis: { exists: jest.fn().mockResolvedValue(0) }, - warmPoolService: { fetchWarmPoolBox }, - runnerService: { - getRandomAvailableRunner: jest.fn().mockResolvedValue({ id: 'runner-1', features: ['linux-capabilities-v2'] }), - }, - boxRepository, - eventEmitter: { emitAsync: jest.fn().mockResolvedValue(undefined) }, - toBoxDto: jest.fn((box) => box), - }) - - await service.create( - { - name: 'cap-box', - image: 'base', - advanced: { capabilities: { add: ['SYS_ADMIN'], drop: [] } }, - } as any, - { id: 'org-1' } as any, - ) - - expect(fetchWarmPoolBox).not.toHaveBeenCalled() - }) - it.each([ [undefined, true], [false, false], diff --git a/apps/api/src/box/services/box.service.ts b/apps/api/src/box/services/box.service.ts index d2db75499..675873b4e 100644 --- a/apps/api/src/box/services/box.service.ts +++ b/apps/api/src/box/services/box.service.ts @@ -39,8 +39,6 @@ import { TypedConfigService } from '../../config/typed-config.service' import { WarmPool } from '../entities/warm-pool.entity' import { BoxDto, BoxVolume } from '../dto/box.dto' import { RunnerAdapterFactory } from '../runner-adapter/runnerAdapter' -import { requiredRunnerFeaturesForCapabilities, runnerSupportsFeatures } from '../constants/runner-features' -import { normalizeBoxAdvancedOptions } from '../common/box-advanced-options' import { validateNetworkAllowList } from '../utils/network-validation.util' import { SshAccess } from '../entities/ssh-access.entity' import { SshAccessDto, SshAccessValidationDto } from '../dto/ssh-access.dto' @@ -180,9 +178,6 @@ export class BoxService { // Restrict box creation to the supported pinned images; reject anything else // at the request boundary (defaults undefined -> base image). const image = assertSupportedImage(createBoxDto.image) - const advanced = normalizeBoxAdvancedOptions(createBoxDto.advanced) - const requiredRunnerFeatures = requiredRunnerFeaturesForCapabilities(advanced.capabilities) - const hasCustomCapabilities = requiredRunnerFeatures.length > 0 this.organizationService.assertOrganizationIsNotSuspended(organization) @@ -191,10 +186,9 @@ export class BoxService { if (createBoxDto.volumes && createBoxDto.volumes.length > 0) { const volumeIdOrNames = createBoxDto.volumes.map((v) => v.volumeId) await this.volumeService.validateVolumes(organization.id, volumeIdOrNames) - } else if (image && !hasCustomCapabilities) { + } else if (image) { // No volumes requested — try to claim a pre-warmed box matching this image/spec - // before creating a fresh one. Warm-pool boxes were created with the - // default capability set, so a custom policy has to build a fresh box. + // before creating a fresh one. const skipWarmPool = (await this.redis.exists(`warm-pool:skip:${image}`)) === 1 if (!skipWarmPool) { const warmPoolBox = await this.warmPoolService.fetchWarmPoolBox({ @@ -220,7 +214,6 @@ export class BoxService { const runner = await this.runnerService.getRandomAvailableRunner({ regions: [region.id], boxClass, - requiredFeatures: requiredRunnerFeatures, }) const box = new Box(region.id, createBoxDto.name) @@ -232,7 +225,6 @@ export class BoxService { // TODO: default user should be configurable box.osUser = createBoxDto.user || 'boxlite' box.env = createBoxDto.env || {} - box.advanced = advanced box.labels = createBoxDto.labels || {} box.image = image @@ -988,13 +980,6 @@ export class BoxService { } const runner = await this.runnerService.findOneOrFail(box.runnerId) - const requiredRunnerFeatures = requiredRunnerFeaturesForCapabilities(box.advanced.capabilities) - if (!runnerSupportsFeatures(runner.features, requiredRunnerFeatures)) { - throw new BadRequestError( - `Runner ${runner.id} does not support required feature: ${requiredRunnerFeatures.join(', ')}`, - ) - } - if (runner.apiVersion === '2') { // TODO: we need "recovering" state that can be set after calling recover // Once in recovering, we abort further processing and let the manager/job handler take care of it diff --git a/apps/api/src/box/services/runner.service.ts b/apps/api/src/box/services/runner.service.ts index 59ed908ce..650edc2c3 100644 --- a/apps/api/src/box/services/runner.service.ts +++ b/apps/api/src/box/services/runner.service.ts @@ -44,7 +44,6 @@ import { BoxDesiredState } from '../enums/box-desired-state.enum' import { runnerLookupCacheKeyById, RUNNER_LOOKUP_CACHE_TTL_MS } from '../utils/runner-lookup-cache.util' import { BoxRepository } from '../repositories/box.repository' import { RunnerServiceInfo } from '../common/runner-service-info' -import { runnerSupportsFeatures } from '../constants/runner-features' @Injectable() export class RunnerService { @@ -313,10 +312,7 @@ export class RunnerService { where: runnerFilter, }) - return runners - .filter((runner) => runnerSupportsFeatures(runner.features, params.requiredFeatures)) - .sort((a, b) => b.availabilityScore - a.availabilityScore) - .slice(0, 10) + return runners.sort((a, b) => b.availabilityScore - a.availabilityScore).slice(0, 10) } /** @@ -374,7 +370,6 @@ export class RunnerService { diskGiB?: number }, appVersion?: string, - features?: string[], ): Promise { const runner = await this.findOne(runnerId) if (!runner) { @@ -408,10 +403,6 @@ export class RunnerService { updateData.appVersion = appVersion } - // Absence means an older runner. Clearing on every heartbeat prevents a - // downgraded runner from retaining capabilities it no longer advertises. - updateData.features = [...new Set(features ?? [])] - if (serviceHealth !== undefined) { updateData.serviceHealth = serviceHealth } else { @@ -563,7 +554,6 @@ export class RunnerService { runnerInfo?.serviceHealth, runnerInfo?.metrics, runnerInfo?.appVersion, - runnerInfo?.features, ) })(), new Promise((_, reject) => { @@ -733,10 +723,7 @@ export class RunnerService { const availableRunners = await this.findAvailableRunners(params) if (availableRunners.length === 0) { - const required = params.requiredFeatures?.join(', ') - throw new BadRequestError( - required ? `No available runners support required features: ${required}` : 'No available runners', - ) + throw new BadRequestError('No available runners') } // Get random runner from the best available runners @@ -927,7 +914,6 @@ export class GetRunnerParams { boxClass?: BoxClass excludedRunnerIds?: string[] availabilityScoreThreshold?: number - requiredFeatures?: string[] } interface AvailabilityScoreParams { diff --git a/apps/api/src/box/utils/capability-validation.util.ts b/apps/api/src/box/utils/capability-validation.util.ts deleted file mode 100644 index f88b8326b..000000000 --- a/apps/api/src/box/utils/capability-validation.util.ts +++ /dev/null @@ -1,63 +0,0 @@ -/* - * Copyright 2025 BoxLite AI - * SPDX-License-Identifier: AGPL-3.0 - */ - -import { ValidationArguments, ValidatorConstraint, ValidatorConstraintInterface } from 'class-validator' - -export function isValidLinuxCapabilityName(value: unknown): boolean { - if ( - typeof value !== 'string' || - value.length === 0 || - [...value].some((character) => character.charCodeAt(0) > 0x7f) - ) { - return false - } - - const normalized = value.toUpperCase() - if (normalized === 'ALL') { - return true - } - - const name = normalized.startsWith('CAP_') ? normalized.slice(4) : normalized - return /^[A-Z][A-Z0-9_]*$/.test(name) -} - -@ValidatorConstraint({ name: 'isLinuxCapabilityName', async: false }) -export class IsLinuxCapabilityNameConstraint implements ValidatorConstraintInterface { - validate(value: unknown): boolean { - return isValidLinuxCapabilityName(value) - } - - defaultMessage(): string { - return 'each capability must be a Linux capability name or ALL' - } -} - -/** - * Reject keys the capability policy does not define. - * - * The global validation pipe does not strip unknown properties, so a - * misspelled security field would otherwise be accepted and ignored — the box - * would start with the default capability set while the caller believes a - * policy was applied. - */ -@ValidatorConstraint({ name: 'hasNoUnknownCapabilityFields', async: false }) -export class HasNoUnknownCapabilityFieldsConstraint implements ValidatorConstraintInterface { - validate(value: unknown, args: ValidationArguments): boolean { - if (typeof value !== 'object' || value === null) { - return true - } - // class-transformer materializes declared-but-absent keys as undefined, so - // presence alone does not mean the caller sent them. - const known = args.constraints[0] as string[] - return Object.entries(value as Record) - .filter(([, entry]) => entry !== undefined) - .every(([key]) => known.includes(key)) - } - - defaultMessage(args: ValidationArguments): string { - const known = (args.constraints[0] as string[]).join(', ') - return `${args.property} accepts only: ${known}` - } -} diff --git a/apps/api/src/boxlite-rest/boxlite-box.controller.ts b/apps/api/src/boxlite-rest/boxlite-box.controller.ts index 5fa9fc88c..e50184b44 100644 --- a/apps/api/src/boxlite-rest/boxlite-box.controller.ts +++ b/apps/api/src/boxlite-rest/boxlite-box.controller.ts @@ -75,7 +75,6 @@ export class BoxliteBoxController { working_dir: req.body?.working_dir, entrypoint: req.body?.entrypoint, cmd: req.body?.cmd, - advanced: req.body?.advanced, detach: req.body?.detach, auto_pause: req.body?.auto_pause, auto_delete: req.body?.auto_delete, diff --git a/apps/api/src/boxlite-rest/boxlite-config.controller.spec.ts b/apps/api/src/boxlite-rest/boxlite-config.controller.spec.ts deleted file mode 100644 index e5d4d4936..000000000 --- a/apps/api/src/boxlite-rest/boxlite-config.controller.spec.ts +++ /dev/null @@ -1,15 +0,0 @@ -/* - * Copyright 2025 Daytona Platforms Inc. - * Modified by BoxLite AI, 2025-2026 - * SPDX-License-Identifier: AGPL-3.0 - */ - -import { BoxliteConfigController } from './boxlite-config.controller' - -describe('BoxliteConfigController', () => { - it('advertises Linux capability policy support', () => { - const config = new BoxliteConfigController().getConfig() - - expect(config.capabilities.linux_capabilities_enabled).toBe(true) - }) -}) diff --git a/apps/api/src/boxlite-rest/boxlite-config.controller.ts b/apps/api/src/boxlite-rest/boxlite-config.controller.ts index a6017affd..96e8a030f 100644 --- a/apps/api/src/boxlite-rest/boxlite-config.controller.ts +++ b/apps/api/src/boxlite-rest/boxlite-config.controller.ts @@ -16,7 +16,6 @@ export class BoxliteConfigController { getConfig() { return { capabilities: { - linux_capabilities_enabled: true, snapshots_enabled: false, clone_enabled: false, export_enabled: false, diff --git a/apps/api/src/boxlite-rest/boxlite-rest-routing.spec.ts b/apps/api/src/boxlite-rest/boxlite-rest-routing.spec.ts index ad03ef283..fbafa67d2 100644 --- a/apps/api/src/boxlite-rest/boxlite-rest-routing.spec.ts +++ b/apps/api/src/boxlite-rest/boxlite-rest-routing.spec.ts @@ -12,7 +12,6 @@ import { CombinedAuthGuard } from '../auth/combined-auth.guard' import { OrganizationResourceActionGuard } from '../organization/guards/organization-resource-action.guard' import { BoxService } from '../box/services/box.service' import { BoxStateWaiterService } from '../box/services/box-state-waiter.service' -import { BoxState } from '../box/enums/box-state.enum' import { BoxliteBoxController } from './boxlite-box.controller' import { BoxliteProxyController } from './boxlite-proxy.controller' import { BoxliteWsProxyService } from './boxlite-ws-proxy.service' @@ -38,13 +37,6 @@ describe('BoxLite REST routing', () => { useValue: { findAllDeprecated: jest.fn().mockResolvedValue([]), toBoxDtos: jest.fn().mockResolvedValue([]), - findOneByIdOrName: jest.fn().mockResolvedValue({ id: 'box-1' }), - toBoxDto: jest.fn().mockResolvedValue({ - id: 'box-1', - name: 'named', - state: BoxState.STARTED, - labels: {}, - }), }, }, { @@ -86,14 +78,6 @@ describe('BoxLite REST routing', () => { expect(Reflect.getMetadata(PATH_METADATA, BoxliteProxyController)).toEqual(['v1/boxes', 'v1/:prefix/boxes']) }) - it('does not register capability-specific read aliases', () => { - const listPath = Reflect.getMetadata(PATH_METADATA, BoxliteBoxController.prototype.listBoxes) - const getPath = Reflect.getMetadata(PATH_METADATA, BoxliteBoxController.prototype.getBox) - - expect(listPath).toBe('/') - expect(getPath).toBe(':boxId') - }) - it('registers canonical and legacy default-prefix routes in the Nest HTTP router', async () => { await startRoutingTestApp() @@ -106,19 +90,6 @@ describe('BoxLite REST routing', () => { expect(await legacy.json()).toEqual({ boxes: [] }) }) - it('serves the box read route without exposing capability metadata', async () => { - await startRoutingTestApp() - - const canonical = await get('/api/v1/boxes/named') - const prefixed = await get('/api/v1/default/boxes/named') - - expect(canonical.status).toBe(200) - const body = await canonical.json() - expect(body).toMatchObject({ box_id: 'box-1' }) - expect(body.advanced).toBeUndefined() - expect(prefixed.status).toBe(200) - }) - it('matches websocket attach upgrades with or without a routing prefix', () => { const service = new BoxliteWsProxyService( {} as any, diff --git a/apps/api/src/boxlite-rest/dto/create-box.dto.spec.ts b/apps/api/src/boxlite-rest/dto/create-box.dto.spec.ts index d3e104f24..962547cb3 100644 --- a/apps/api/src/boxlite-rest/dto/create-box.dto.spec.ts +++ b/apps/api/src/boxlite-rest/dto/create-box.dto.spec.ts @@ -42,7 +42,9 @@ describe('CreateBoxDto resource minimums', () => { describe('CreateBoxDto lifecycle policy', () => { it('accepts second-based lifecycle fields', async () => { - const errors = await validate(plainToInstance(CreateBoxDto, { auto_pause: 900, auto_delete: 604800 })) + const errors = await validate( + plainToInstance(CreateBoxDto, { auto_pause: 900, auto_delete: 604800 }), + ) expect(errors).toHaveLength(0) }) @@ -122,65 +124,3 @@ describe('CreateBoxDto network validation', () => { expect(JSON.stringify(errors)).toContain('isIn') }) }) - -describe('CreateBoxDto capability validation', () => { - it.each([ - ['advanced', { advanced: null }], - ['advanced.capabilities', { advanced: { capabilities: null } }], - ['advanced.capabilities.add', { advanced: { capabilities: { add: null } } }], - ['advanced.capabilities.drop', { advanced: { capabilities: { drop: null } } }], - ])('rejects explicit null for %s', async (_field, payload) => { - const errors = await validate(plainToInstance(CreateBoxDto, payload)) - - expect(errors).not.toHaveLength(0) - }) - - it('accepts Docker-style capability names', async () => { - const errors = await validate( - plainToInstance(CreateBoxDto, { - advanced: { - capabilities: { - add: ['sys_admin', 'CAP_NET_ADMIN', 'ALL'], - drop: ['NET_RAW'], - }, - }, - }), - ) - - expect(errors).toHaveLength(0) - }) - - it('accepts a syntactically valid capability that a newer guest may support', async () => { - const errors = await validate( - plainToInstance(CreateBoxDto, { - advanced: { capabilities: { add: ['FUTURE_KERNEL_FEATURE'] } }, - }), - ) - - expect(errors).toHaveLength(0) - }) - - it.each([ - ['advanced', 'advanced', { advanced: { capabilites: { drop: ['NET_RAW'] } } }], - ['advanced.capabilities', 'capabilities', { advanced: { capabilities: { dorp: ['NET_RAW'] } } }], - ])('rejects an unknown key under %s instead of ignoring it', async (_label, property, payload) => { - const errors = await validate(plainToInstance(CreateBoxDto, payload)) - const flattened = [...errors, ...errors.flatMap((error) => error.children ?? [])] - - expect(flattened.find((error) => error.property === property)?.constraints).toHaveProperty( - 'hasNoUnknownCapabilityFields', - ) - }) - - it('rejects malformed capability names', async () => { - for (const capability of ['NET-ADMIN', '123', 'ß']) { - const errors = await validate( - plainToInstance(CreateBoxDto, { - advanced: { capabilities: { add: [capability] } }, - }), - ) - - expect(JSON.stringify(errors)).toContain('isLinuxCapabilityName') - } - }) -}) diff --git a/apps/api/src/boxlite-rest/dto/create-box.dto.ts b/apps/api/src/boxlite-rest/dto/create-box.dto.ts index 2fcf48daf..9e0310371 100644 --- a/apps/api/src/boxlite-rest/dto/create-box.dto.ts +++ b/apps/api/src/boxlite-rest/dto/create-box.dto.ts @@ -16,14 +16,11 @@ import { Min, IsIn, Validate, - ValidateIf, ValidateNested, ValidatorConstraint, ValidatorConstraintInterface, } from 'class-validator' import { isValidNetworkAllowEntry, MAX_NETWORK_ALLOW_LIST_ENTRIES } from '../../box/utils/network-validation.util' -import { CreateBoxAdvancedOptionsDto } from '../../box/dto/create-box.dto' -import { HasNoUnknownCapabilityFieldsConstraint } from '../../box/utils/capability-validation.util' @ValidatorConstraint({ name: 'isNetworkAllowEntry', async: false }) class IsNetworkAllowEntryConstraint implements ValidatorConstraintInterface { @@ -95,13 +92,6 @@ export class CreateBoxDto { @IsString() user?: string - @ValidateIf((_object, value) => value !== undefined) - @IsObject() - @ValidateNested() - @Validate(HasNoUnknownCapabilityFieldsConstraint, [['capabilities']]) - @Type(() => CreateBoxAdvancedOptionsDto) - advanced?: CreateBoxAdvancedOptionsDto - @IsOptional() @IsBoolean() detach?: boolean diff --git a/apps/api/src/boxlite-rest/mappers/box-to-box.mapper.spec.ts b/apps/api/src/boxlite-rest/mappers/box-to-box.mapper.spec.ts index e8b92f5b1..fd0867021 100644 --- a/apps/api/src/boxlite-rest/mappers/box-to-box.mapper.spec.ts +++ b/apps/api/src/boxlite-rest/mappers/box-to-box.mapper.spec.ts @@ -7,20 +7,6 @@ import { BoxState } from '../../box/enums/box-state.enum' import { boxToBoxResponse, createBoxToCreateBox } from './box-to-box.mapper' describe('BoxLite lifecycle policy mapper', () => { - it('maps capability overrides into the control-plane DTO', () => { - const mapped = createBoxToCreateBox({ - advanced: { - capabilities: { - add: ['SYS_ADMIN'], - drop: ['CAP_NET_RAW'], - }, - }, - } as any) - - expect(mapped.advanced?.capabilities?.add).toEqual(['SYS_ADMIN']) - expect(mapped.advanced?.capabilities?.drop).toEqual(['CAP_NET_RAW']) - }) - it('maps second-based create fields into the control-plane DTO', () => { const mapped = createBoxToCreateBox({ auto_pause: 1800, @@ -49,18 +35,6 @@ describe('BoxLite lifecycle policy mapper', () => { expect(response.auto_resume).toBe(false) }) - it('omits persisted capabilities from the public response', () => { - const response = boxToBoxResponse({ - id: 'box-1', - name: 'demo', - state: BoxState.STARTED, - labels: {}, - advanced: { capabilities: { add: ['SYS_ADMIN'], drop: ['NET_RAW'] } }, - } as any) - - expect((response as any).advanced).toBeUndefined() - }) - it('defaults auto_resume to true when missing', () => { const response = boxToBoxResponse({ id: 'box-1', diff --git a/apps/api/src/boxlite-rest/mappers/box-to-box.mapper.ts b/apps/api/src/boxlite-rest/mappers/box-to-box.mapper.ts index 8deaa2d64..f74a91e78 100644 --- a/apps/api/src/boxlite-rest/mappers/box-to-box.mapper.ts +++ b/apps/api/src/boxlite-rest/mappers/box-to-box.mapper.ts @@ -38,7 +38,6 @@ export function createBoxToCreateBox(dto: RestCreateBoxDto, target?: string): Cr createDto.image = dto.image createDto.user = dto.user createDto.env = dto.env - createDto.advanced = dto.advanced createDto.cpu = dto.cpus createDto.memory = dto.memory_mib ? Math.ceil(dto.memory_mib / 1024) : undefined createDto.disk = dto.disk_size_gb diff --git a/apps/api/src/migrations/pre-deploy/1785000000000-add-box-capabilities-migration.spec.ts b/apps/api/src/migrations/pre-deploy/1785000000000-add-box-capabilities-migration.spec.ts deleted file mode 100644 index 9f42bace4..000000000 --- a/apps/api/src/migrations/pre-deploy/1785000000000-add-box-capabilities-migration.spec.ts +++ /dev/null @@ -1,40 +0,0 @@ -import { QueryRunner } from 'typeorm' -import { AddBoxCapabilities1785000000000 } from './1785000000000-add-box-capabilities-migration' - -describe('AddBoxCapabilities1785000000000', () => { - it('stores advanced options as one nested JSONB object', async () => { - const query = jest.fn().mockResolvedValue(undefined) - const queryRunner = { query } as unknown as QueryRunner - - await new AddBoxCapabilities1785000000000().up(queryRunner) - - expect(query).toHaveBeenCalledTimes(2) - expect(query.mock.calls[0][0]).toContain(`ADD "advanced" jsonb`) - expect(query.mock.calls[0][0]).toContain(`{"capabilities":{"add":[],"drop":[]}}`) - expect(query.mock.calls[1][0]).toContain(`ALTER TABLE "runner" ADD "features"`) - }) - - it('refuses to discard a persisted custom capability policy on rollback', async () => { - const query = jest.fn().mockResolvedValueOnce([{ hasCustomPolicy: true }]) - const queryRunner = { query } as unknown as QueryRunner - - await expect(new AddBoxCapabilities1785000000000().down(queryRunner)).rejects.toThrow( - 'custom Linux capability policies', - ) - expect(query).toHaveBeenCalledTimes(1) - expect(query.mock.calls[0][0]).toContain('SELECT EXISTS') - }) - - it('allows rollback when every box uses the baseline policy', async () => { - const query = jest.fn().mockResolvedValueOnce([{ hasCustomPolicy: false }]) - const queryRunner = { query } as unknown as QueryRunner - - await new AddBoxCapabilities1785000000000().down(queryRunner) - - expect(query).toHaveBeenCalledTimes(3) - expect(query.mock.calls.slice(1).map(([sql]) => sql)).toEqual([ - `ALTER TABLE "runner" DROP COLUMN "features"`, - `ALTER TABLE "box" DROP COLUMN "advanced"`, - ]) - }) -}) diff --git a/apps/api/src/migrations/pre-deploy/1785000000000-add-box-capabilities-migration.ts b/apps/api/src/migrations/pre-deploy/1785000000000-add-box-capabilities-migration.ts deleted file mode 100644 index 46217c05a..000000000 --- a/apps/api/src/migrations/pre-deploy/1785000000000-add-box-capabilities-migration.ts +++ /dev/null @@ -1,30 +0,0 @@ -import { MigrationInterface, QueryRunner } from 'typeorm' - -export class AddBoxCapabilities1785000000000 implements MigrationInterface { - name = 'AddBoxCapabilities1785000000000' - - public async up(queryRunner: QueryRunner): Promise { - await queryRunner.query( - `ALTER TABLE "box" ADD "advanced" jsonb NOT NULL DEFAULT '{"capabilities":{"add":[],"drop":[]}}'::jsonb`, - ) - await queryRunner.query(`ALTER TABLE "runner" ADD "features" jsonb NOT NULL DEFAULT '[]'::jsonb`) - } - - public async down(queryRunner: QueryRunner): Promise { - const [{ hasCustomPolicy }] = (await queryRunner.query(` - SELECT EXISTS ( - SELECT 1 - FROM "box" - WHERE "advanced" <> '{"capabilities":{"add":[],"drop":[]}}'::jsonb - ) AS "hasCustomPolicy" - `)) as Array<{ hasCustomPolicy: boolean }> - if (hasCustomPolicy) { - throw new Error( - 'Cannot roll back advanced options while boxes have custom Linux capability policies', - ) - } - - await queryRunner.query(`ALTER TABLE "runner" DROP COLUMN "features"`) - await queryRunner.query(`ALTER TABLE "box" DROP COLUMN "advanced"`) - } -} diff --git a/apps/libs/api-client/src/docs/RunnerHealthcheck.md b/apps/libs/api-client/src/docs/RunnerHealthcheck.md index 1ed624f8b..7834094b5 100644 --- a/apps/libs/api-client/src/docs/RunnerHealthcheck.md +++ b/apps/libs/api-client/src/docs/RunnerHealthcheck.md @@ -11,7 +11,6 @@ Name | Type | Description | Notes **proxyUrl** | **string** | Runner proxy URL | [optional] [default to undefined] **apiUrl** | **string** | Runner API URL | [optional] [default to undefined] **appVersion** | **string** | Runner app version | [default to undefined] -**features** | **Array<string>** | Optional runner features used for rollout negotiation | [optional] [default to undefined] ## Example @@ -25,7 +24,6 @@ const instance: RunnerHealthcheck = { proxyUrl, apiUrl, appVersion, - features, }; ``` diff --git a/apps/libs/api-client/src/models/runner-healthcheck.ts b/apps/libs/api-client/src/models/runner-healthcheck.ts index 9142f0e40..aec0522d1 100644 --- a/apps/libs/api-client/src/models/runner-healthcheck.ts +++ b/apps/libs/api-client/src/models/runner-healthcheck.ts @@ -45,9 +45,5 @@ export interface RunnerHealthcheck { * Runner app version */ 'appVersion': string; - /** - * Optional runner features used for rollout negotiation - */ - 'features'?: Array; } diff --git a/apps/libs/runner-api-client/src/.openapi-generator/FILES b/apps/libs/runner-api-client/src/.openapi-generator/FILES index 078b8a938..c380051b3 100644 --- a/apps/libs/runner-api-client/src/.openapi-generator/FILES +++ b/apps/libs/runner-api-client/src/.openapi-generator/FILES @@ -9,14 +9,12 @@ api/snapshots-api.ts base.ts common.ts configuration.ts -docs/AdvancedBoxOptionsDTO.md docs/BoxApi.md docs/BoxInfoResponse.md docs/BoxliteApi.md docs/BuildSnapshotRequestDTO.md docs/CreateBackupDTO.md docs/CreateBoxDTO.md -docs/ContainerCapabilitiesDTO.md docs/DefaultApi.md docs/DtoVolumeDTO.md docs/EnumsBackupState.md @@ -41,12 +39,10 @@ docs/TagImageRequestDTO.md docs/UpdateNetworkSettingsDTO.md git_push.sh index.ts -models/advanced-box-options-dto.ts models/box-info-response.ts models/build-snapshot-request-dto.ts models/create-backup-dto.ts models/create-box-dto.ts -models/container-capabilities-dto.ts models/dto-volume-dto.ts models/enums-backup-state.ts models/enums-box-state.ts diff --git a/apps/libs/runner-api-client/src/docs/AdvancedBoxOptionsDTO.md b/apps/libs/runner-api-client/src/docs/AdvancedBoxOptionsDTO.md deleted file mode 100644 index 387505ee0..000000000 --- a/apps/libs/runner-api-client/src/docs/AdvancedBoxOptionsDTO.md +++ /dev/null @@ -1,9 +0,0 @@ -# AdvancedBoxOptionsDTO - -## Properties - -Name | Type | Description | Notes ------------- | ------------- | ------------- | ------------- -**capabilities** | [**ContainerCapabilitiesDTO**](ContainerCapabilitiesDTO.md) | Linux capability policy | [default to undefined] - -[[Back to Model list]](../README.md#documentation-for-models) diff --git a/apps/libs/runner-api-client/src/docs/ContainerCapabilitiesDTO.md b/apps/libs/runner-api-client/src/docs/ContainerCapabilitiesDTO.md deleted file mode 100644 index 40fd18924..000000000 --- a/apps/libs/runner-api-client/src/docs/ContainerCapabilitiesDTO.md +++ /dev/null @@ -1,10 +0,0 @@ -# ContainerCapabilitiesDTO - -## Properties - -Name | Type | Description | Notes ------------- | ------------- | ------------- | ------------- -**add** | **Array<string>** | Linux capabilities to add | [optional] [default to undefined] -**drop** | **Array<string>** | Linux capabilities to drop | [optional] [default to undefined] - -[[Back to Model list]](../README.md#documentation-for-models) diff --git a/apps/libs/runner-api-client/src/docs/CreateBoxDTO.md b/apps/libs/runner-api-client/src/docs/CreateBoxDTO.md index b1dff2ae8..9c8ddb838 100644 --- a/apps/libs/runner-api-client/src/docs/CreateBoxDTO.md +++ b/apps/libs/runner-api-client/src/docs/CreateBoxDTO.md @@ -5,7 +5,6 @@ Name | Type | Description | Notes ------------ | ------------- | ------------- | ------------- -**advanced** | [**AdvancedBoxOptionsDTO**](AdvancedBoxOptionsDTO.md) | | [optional] [default to undefined] **authToken** | **string** | | [optional] [default to undefined] **cpuQuota** | **number** | | [optional] [default to undefined] **entrypoint** | **Array<string>** | | [optional] [default to undefined] diff --git a/apps/libs/runner-api-client/src/docs/RecoverBoxDTO.md b/apps/libs/runner-api-client/src/docs/RecoverBoxDTO.md index 01d0aac51..1d650bfeb 100644 --- a/apps/libs/runner-api-client/src/docs/RecoverBoxDTO.md +++ b/apps/libs/runner-api-client/src/docs/RecoverBoxDTO.md @@ -5,7 +5,6 @@ Name | Type | Description | Notes ------------ | ------------- | ------------- | ------------- -**advanced** | [**AdvancedBoxOptionsDTO**](AdvancedBoxOptionsDTO.md) | | [optional] [default to undefined] **backupErrorReason** | **string** | | [optional] [default to undefined] **cpuQuota** | **number** | | [optional] [default to undefined] **env** | **{ [key: string]: string; }** | | [optional] [default to undefined] diff --git a/apps/libs/runner-api-client/src/docs/RunnerInfoResponseDTO.md b/apps/libs/runner-api-client/src/docs/RunnerInfoResponseDTO.md index 8fe64c3c8..d74bcf23f 100644 --- a/apps/libs/runner-api-client/src/docs/RunnerInfoResponseDTO.md +++ b/apps/libs/runner-api-client/src/docs/RunnerInfoResponseDTO.md @@ -6,7 +6,6 @@ Name | Type | Description | Notes ------------ | ------------- | ------------- | ------------- **appVersion** | **string** | | [optional] [default to undefined] -**features** | **Array<string>** | | [optional] [default to undefined] **metrics** | [**RunnerMetrics**](RunnerMetrics.md) | | [optional] [default to undefined] **serviceHealth** | [**Array<RunnerServiceInfo>**](RunnerServiceInfo.md) | | [optional] [default to undefined] @@ -17,7 +16,6 @@ import { RunnerInfoResponseDTO } from './api'; const instance: RunnerInfoResponseDTO = { appVersion, - features, metrics, serviceHealth, }; diff --git a/apps/libs/runner-api-client/src/models/advanced-box-options-dto.ts b/apps/libs/runner-api-client/src/models/advanced-box-options-dto.ts deleted file mode 100644 index 9b8d67d8f..000000000 --- a/apps/libs/runner-api-client/src/models/advanced-box-options-dto.ts +++ /dev/null @@ -1,15 +0,0 @@ -/* tslint:disable */ -/* eslint-disable */ -/** - * BoxLite Runner API - * BoxLite Runner API - * - * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). - * Do not edit the class manually. - */ - -import type { ContainerCapabilitiesDTO } from './container-capabilities-dto'; - -export interface AdvancedBoxOptionsDTO { - 'capabilities': ContainerCapabilitiesDTO; -} diff --git a/apps/libs/runner-api-client/src/models/container-capabilities-dto.ts b/apps/libs/runner-api-client/src/models/container-capabilities-dto.ts deleted file mode 100644 index 401d902c5..000000000 --- a/apps/libs/runner-api-client/src/models/container-capabilities-dto.ts +++ /dev/null @@ -1,14 +0,0 @@ -/* tslint:disable */ -/* eslint-disable */ -/** - * BoxLite Runner API - * BoxLite Runner API - * - * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). - * Do not edit the class manually. - */ - -export interface ContainerCapabilitiesDTO { - 'add'?: Array; - 'drop'?: Array; -} diff --git a/apps/libs/runner-api-client/src/models/create-box-dto.ts b/apps/libs/runner-api-client/src/models/create-box-dto.ts index 72fb102ea..2558b7e2c 100644 --- a/apps/libs/runner-api-client/src/models/create-box-dto.ts +++ b/apps/libs/runner-api-client/src/models/create-box-dto.ts @@ -13,9 +13,6 @@ */ -// May contain unused imports in some cases -// @ts-ignore -import type { AdvancedBoxOptionsDTO } from './advanced-box-options-dto'; // May contain unused imports in some cases // @ts-ignore import type { DtoVolumeDTO } from './dto-volume-dto'; @@ -24,7 +21,6 @@ import type { DtoVolumeDTO } from './dto-volume-dto'; import type { RegistryDTO } from './registry-dto'; export interface CreateBoxDTO { - 'advanced'?: AdvancedBoxOptionsDTO; 'authToken'?: string; 'cpuQuota'?: number; 'entrypoint'?: Array; diff --git a/apps/libs/runner-api-client/src/models/index.ts b/apps/libs/runner-api-client/src/models/index.ts index cce139281..daace8247 100644 --- a/apps/libs/runner-api-client/src/models/index.ts +++ b/apps/libs/runner-api-client/src/models/index.ts @@ -1,9 +1,7 @@ -export * from './advanced-box-options-dto'; export * from './box-info-response'; export * from './build-snapshot-request-dto'; export * from './create-backup-dto'; export * from './create-box-dto'; -export * from './container-capabilities-dto'; export * from './dto-volume-dto'; export * from './enums-backup-state'; export * from './enums-box-state'; diff --git a/apps/libs/runner-api-client/src/models/recover-box-dto.ts b/apps/libs/runner-api-client/src/models/recover-box-dto.ts index 3aa199fe2..0338486f3 100644 --- a/apps/libs/runner-api-client/src/models/recover-box-dto.ts +++ b/apps/libs/runner-api-client/src/models/recover-box-dto.ts @@ -13,15 +13,11 @@ */ -// May contain unused imports in some cases -// @ts-ignore -import type { AdvancedBoxOptionsDTO } from './advanced-box-options-dto'; // May contain unused imports in some cases // @ts-ignore import type { DtoVolumeDTO } from './dto-volume-dto'; export interface RecoverBoxDTO { - 'advanced'?: AdvancedBoxOptionsDTO; 'backupErrorReason'?: string; 'cpuQuota'?: number; 'env'?: { [key: string]: string; }; diff --git a/apps/libs/runner-api-client/src/models/runner-info-response-dto.ts b/apps/libs/runner-api-client/src/models/runner-info-response-dto.ts index 1ad71f658..b866561b3 100644 --- a/apps/libs/runner-api-client/src/models/runner-info-response-dto.ts +++ b/apps/libs/runner-api-client/src/models/runner-info-response-dto.ts @@ -22,7 +22,6 @@ import type { RunnerServiceInfo } from './runner-service-info'; export interface RunnerInfoResponseDTO { 'appVersion'?: string; - 'features'?: Array; 'metrics'?: RunnerMetrics; 'serviceHealth'?: Array; } diff --git a/apps/runner/internal/features.go b/apps/runner/internal/features.go deleted file mode 100644 index 31aeb7bc5..000000000 --- a/apps/runner/internal/features.go +++ /dev/null @@ -1,8 +0,0 @@ -// Copyright 2025 BoxLite AI -// SPDX-License-Identifier: AGPL-3.0 - -package internal - -// FeatureLinuxCapabilitiesV2 advertises support for the nested -// advanced.capabilities create policy. -const FeatureLinuxCapabilitiesV2 = "linux-capabilities-v2" diff --git a/apps/runner/pkg/api/controllers/box.go b/apps/runner/pkg/api/controllers/box.go index 51c049220..e0496e7d7 100644 --- a/apps/runner/pkg/api/controllers/box.go +++ b/apps/runner/pkg/api/controllers/box.go @@ -67,12 +67,12 @@ func Create(ctx *gin.Context) { // @Description Destroy box // @Produce json // @Param boxId path string true "Box ID" -// @Success 200 {string} string "Box destroyed" -// @Failure 400 {object} common_errors.ErrorResponse -// @Failure 401 {object} common_errors.ErrorResponse -// @Failure 404 {object} common_errors.ErrorResponse -// @Failure 409 {object} common_errors.ErrorResponse -// @Failure 500 {object} common_errors.ErrorResponse +// @Success 200 {string} string "Box destroyed" +// @Failure 400 {object} common_errors.ErrorResponse +// @Failure 401 {object} common_errors.ErrorResponse +// @Failure 404 {object} common_errors.ErrorResponse +// @Failure 409 {object} common_errors.ErrorResponse +// @Failure 500 {object} common_errors.ErrorResponse // @Router /boxes/{boxId}/destroy [post] // // @id Destroy @@ -105,12 +105,12 @@ func Destroy(ctx *gin.Context) { // @Produce json // @Param boxId path string true "Box ID" // @Param box body dto.UpdateNetworkSettingsDTO true "Update network settings" -// @Success 200 {string} string "Network settings updated" -// @Failure 400 {object} common_errors.ErrorResponse -// @Failure 401 {object} common_errors.ErrorResponse -// @Failure 404 {object} common_errors.ErrorResponse -// @Failure 409 {object} common_errors.ErrorResponse -// @Failure 500 {object} common_errors.ErrorResponse +// @Success 200 {string} string "Network settings updated" +// @Failure 400 {object} common_errors.ErrorResponse +// @Failure 401 {object} common_errors.ErrorResponse +// @Failure 404 {object} common_errors.ErrorResponse +// @Failure 409 {object} common_errors.ErrorResponse +// @Failure 500 {object} common_errors.ErrorResponse // @Router /boxes/{boxId}/network-settings [post] // // @id UpdateNetworkSettings @@ -144,9 +144,9 @@ func UpdateNetworkSettings(ctx *gin.Context) { // @Summary Start box // @Description Start box // @Produce json -// @Param boxId path string true "Box ID" -// @Param metadata body object false "Metadata" -// @Param token query string false "Auth token" +// @Param boxId path string true "Box ID" +// @Param metadata body object false "Metadata" +// @Param token query string false "Auth token" // @Success 200 {object} dto.StartBoxResponse "Box started" // @Failure 400 {object} common_errors.ErrorResponse // @Failure 401 {object} common_errors.ErrorResponse @@ -195,14 +195,14 @@ func Start(ctx *gin.Context) { // @Summary Stop box // @Description Stop box // @Produce json -// @Param boxId path string true "Box ID" +// @Param boxId path string true "Box ID" // @Param box body dto.StopBoxDTO false "Stop box" -// @Success 200 {string} string "Box stopped" -// @Failure 400 {object} common_errors.ErrorResponse -// @Failure 401 {object} common_errors.ErrorResponse -// @Failure 404 {object} common_errors.ErrorResponse -// @Failure 409 {object} common_errors.ErrorResponse -// @Failure 500 {object} common_errors.ErrorResponse +// @Success 200 {string} string "Box stopped" +// @Failure 400 {object} common_errors.ErrorResponse +// @Failure 401 {object} common_errors.ErrorResponse +// @Failure 404 {object} common_errors.ErrorResponse +// @Failure 409 {object} common_errors.ErrorResponse +// @Failure 500 {object} common_errors.ErrorResponse // @Router /boxes/{boxId}/stop [post] // // @id Stop @@ -234,13 +234,13 @@ func Stop(ctx *gin.Context) { // @Summary Get box info // @Description Get box info // @Produce json -// @Param boxId path string true "Box ID" -// @Success 200 {object} BoxInfoResponse "Box info" -// @Failure 400 {object} common_errors.ErrorResponse -// @Failure 401 {object} common_errors.ErrorResponse -// @Failure 404 {object} common_errors.ErrorResponse -// @Failure 409 {object} common_errors.ErrorResponse -// @Failure 500 {object} common_errors.ErrorResponse +// @Param boxId path string true "Box ID" +// @Success 200 {object} BoxInfoResponse "Box info" +// @Failure 400 {object} common_errors.ErrorResponse +// @Failure 401 {object} common_errors.ErrorResponse +// @Failure 404 {object} common_errors.ErrorResponse +// @Failure 409 {object} common_errors.ErrorResponse +// @Failure 500 {object} common_errors.ErrorResponse // @Router /boxes/{boxId} [get] // // @id Info @@ -285,9 +285,9 @@ type BoxInfoResponse struct { // @Tags box // @Accept json // @Produce json -// @Param boxId path string true "Box ID" +// @Param boxId path string true "Box ID" // @Param recovery body dto.RecoverBoxDTO true "Recovery parameters" -// @Success 200 {string} string "Box recovered" +// @Success 200 {string} string "Box recovered" // @Failure 400 {object} common_errors.ErrorResponse // @Failure 401 {object} common_errors.ErrorResponse // @Failure 404 {object} common_errors.ErrorResponse @@ -328,9 +328,9 @@ func Recover(ctx *gin.Context) { // @Accept json // @Produce json // @Param boxId path string true "Box ID" -// @Param request body dto.IsRecoverableDTO true "Error reason to check" -// @Success 200 {object} dto.IsRecoverableResponse -// @Failure 400 {object} common_errors.ErrorResponse +// @Param request body dto.IsRecoverableDTO true "Error reason to check" +// @Success 200 {object} dto.IsRecoverableResponse +// @Failure 400 {object} common_errors.ErrorResponse // @Router /boxes/{boxId}/is-recoverable [post] // // @id IsRecoverable diff --git a/apps/runner/pkg/api/controllers/info.go b/apps/runner/pkg/api/controllers/info.go index ab148420e..f33d7f8c2 100644 --- a/apps/runner/pkg/api/controllers/info.go +++ b/apps/runner/pkg/api/controllers/info.go @@ -51,7 +51,6 @@ func RunnerInfo(ctx *gin.Context) { CurrentStartedBoxes: int64(metrics.StartedBoxCount), }, AppVersion: internal.Version, - Features: []string{internal.FeatureLinuxCapabilitiesV2}, } ctx.JSON(http.StatusOK, response) diff --git a/apps/runner/pkg/api/docs/docs.go b/apps/runner/pkg/api/docs/docs.go index b6bacde3b..20cde8aa3 100644 --- a/apps/runner/pkg/api/docs/docs.go +++ b/apps/runner/pkg/api/docs/docs.go @@ -1156,18 +1156,6 @@ const docTemplate = `{ } }, "definitions": { - "AdvancedBoxOptionsDTO": { - "type": "object", - "required": [ - "capabilities" - ], - "additionalProperties": false, - "properties": { - "capabilities": { - "$ref": "#/definitions/ContainerCapabilitiesDTO" - } - } - }, "BoxInfoResponse": { "type": "object", "properties": { @@ -1248,9 +1236,6 @@ const docTemplate = `{ "image" ], "properties": { - "advanced": { - "$ref": "#/definitions/AdvancedBoxOptionsDTO" - }, "authToken": { "type": "string" }, @@ -1330,24 +1315,6 @@ const docTemplate = `{ } } }, - "ContainerCapabilitiesDTO": { - "type": "object", - "additionalProperties": false, - "properties": { - "add": { - "type": "array", - "items": { - "type": "string" - } - }, - "drop": { - "type": "array", - "items": { - "type": "string" - } - } - } - }, "ErrorResponse": { "description": "Error response", "type": "object", @@ -1448,9 +1415,6 @@ const docTemplate = `{ "osUser" ], "properties": { - "advanced": { - "$ref": "#/definitions/AdvancedBoxOptionsDTO" - }, "backupErrorReason": { "type": "string" }, @@ -1528,12 +1492,6 @@ const docTemplate = `{ "appVersion": { "type": "string" }, - "features": { - "type": "array", - "items": { - "type": "string" - } - }, "metrics": { "$ref": "#/definitions/RunnerMetrics" }, diff --git a/apps/runner/pkg/api/docs/swagger.json b/apps/runner/pkg/api/docs/swagger.json index ad2929377..63e16c0dc 100644 --- a/apps/runner/pkg/api/docs/swagger.json +++ b/apps/runner/pkg/api/docs/swagger.json @@ -1078,16 +1078,6 @@ } }, "definitions": { - "AdvancedBoxOptionsDTO": { - "type": "object", - "required": ["capabilities"], - "additionalProperties": false, - "properties": { - "capabilities": { - "$ref": "#/definitions/ContainerCapabilitiesDTO" - } - } - }, "BoxInfoResponse": { "type": "object", "properties": { @@ -1158,9 +1148,6 @@ "type": "object", "required": ["id", "osUser", "image"], "properties": { - "advanced": { - "$ref": "#/definitions/AdvancedBoxOptionsDTO" - }, "authToken": { "type": "string" }, @@ -1240,24 +1227,6 @@ } } }, - "ContainerCapabilitiesDTO": { - "type": "object", - "additionalProperties": false, - "properties": { - "add": { - "type": "array", - "items": { - "type": "string" - } - }, - "drop": { - "type": "array", - "items": { - "type": "string" - } - } - } - }, "ErrorResponse": { "description": "Error response", "type": "object", @@ -1344,9 +1313,6 @@ "type": "object", "required": ["errorReason", "osUser"], "properties": { - "advanced": { - "$ref": "#/definitions/AdvancedBoxOptionsDTO" - }, "backupErrorReason": { "type": "string" }, @@ -1422,12 +1388,6 @@ "appVersion": { "type": "string" }, - "features": { - "type": "array", - "items": { - "type": "string" - } - }, "metrics": { "$ref": "#/definitions/RunnerMetrics" }, diff --git a/apps/runner/pkg/api/docs/swagger.yaml b/apps/runner/pkg/api/docs/swagger.yaml index cfd191c45..a66692434 100644 --- a/apps/runner/pkg/api/docs/swagger.yaml +++ b/apps/runner/pkg/api/docs/swagger.yaml @@ -1,12 +1,4 @@ definitions: - AdvancedBoxOptionsDTO: - additionalProperties: false - properties: - capabilities: - $ref: '#/definitions/ContainerCapabilitiesDTO' - required: - - capabilities - type: object BoxInfoResponse: properties: backupError: @@ -57,8 +49,6 @@ definitions: type: object CreateBoxDTO: properties: - advanced: - $ref: '#/definitions/AdvancedBoxOptionsDTO' authToken: type: string cpuQuota: @@ -117,18 +107,6 @@ definitions: - osUser - image type: object - ContainerCapabilitiesDTO: - additionalProperties: false - properties: - add: - items: - type: string - type: array - drop: - items: - type: string - type: array - type: object ErrorResponse: description: Error response properties: @@ -195,8 +173,6 @@ definitions: type: object RecoverBoxDTO: properties: - advanced: - $ref: '#/definitions/AdvancedBoxOptionsDTO' backupErrorReason: type: string cpuQuota: @@ -252,10 +228,6 @@ definitions: properties: appVersion: type: string - features: - items: - type: string - type: array metrics: $ref: '#/definitions/RunnerMetrics' serviceHealth: diff --git a/apps/runner/pkg/api/dto/box.go b/apps/runner/pkg/api/dto/box.go index 30499d553..d3bb57430 100644 --- a/apps/runner/pkg/api/dto/box.go +++ b/apps/runner/pkg/api/dto/box.go @@ -27,23 +27,8 @@ type CreateBoxDTO struct { // Nullable for backward compatibility OrganizationId *string `json:"organizationId,omitempty"` RegionId *string `json:"regionId,omitempty"` - - Advanced *AdvancedBoxOptionsDTO `json:"advanced,omitempty"` } // @name CreateBoxDTO -type AdvancedBoxOptionsDTO struct { - Capabilities *ContainerCapabilitiesDTO `json:"capabilities,omitempty"` -} // @name AdvancedBoxOptionsDTO - -type ContainerCapabilitiesDTO struct { - Add []string `json:"add,omitempty"` - Drop []string `json:"drop,omitempty"` -} // @name ContainerCapabilitiesDTO - -func (d *ContainerCapabilitiesDTO) IsEmpty() bool { - return d == nil || (len(d.Add) == 0 && len(d.Drop) == 0) -} - type UpdateNetworkSettingsDTO struct { NetworkBlockAll *bool `json:"networkBlockAll,omitempty"` NetworkAllowList *string `json:"networkAllowList,omitempty"` @@ -62,8 +47,6 @@ type RecoverBoxDTO struct { NetworkBlockAll *bool `json:"networkBlockAll,omitempty"` NetworkAllowList *string `json:"networkAllowList,omitempty"` ErrorReason string `json:"errorReason" validate:"required"` - - Advanced *AdvancedBoxOptionsDTO `json:"advanced,omitempty"` } // @name RecoverBoxDTO type IsRecoverableDTO struct { diff --git a/apps/runner/pkg/api/dto/box_capabilities_test.go b/apps/runner/pkg/api/dto/box_capabilities_test.go deleted file mode 100644 index 66edc0edf..000000000 --- a/apps/runner/pkg/api/dto/box_capabilities_test.go +++ /dev/null @@ -1,60 +0,0 @@ -// Copyright 2025 BoxLite AI -// SPDX-License-Identifier: AGPL-3.0 - -package dto - -import ( - "encoding/json" - "reflect" - "testing" -) - -// The control plane and the runner agree on this wire shape; a rename or a -// dropped tag on either side would silently discard the privilege policy. -func TestBoxDTOsRoundTripCapabilityPolicy(t *testing.T) { - payload := `{"advanced":{"capabilities":{"add":["SYS_ADMIN"],"drop":["NET_RAW"]}}}` - - t.Run("create", func(t *testing.T) { - var request CreateBoxDTO - if err := json.Unmarshal([]byte(payload), &request); err != nil { - t.Fatalf("decode create payload: %v", err) - } - assertCapabilityPolicy(t, request.Advanced) - }) - - t.Run("recover", func(t *testing.T) { - var request RecoverBoxDTO - if err := json.Unmarshal([]byte(payload), &request); err != nil { - t.Fatalf("decode recover payload: %v", err) - } - assertCapabilityPolicy(t, request.Advanced) - }) -} - -func TestBoxDTOsOmitAnUnsetCapabilityPolicy(t *testing.T) { - encoded, err := json.Marshal(CreateBoxDTO{}) - if err != nil { - t.Fatalf("marshal create DTO: %v", err) - } - - var wire map[string]any - if err := json.Unmarshal(encoded, &wire); err != nil { - t.Fatalf("decode round-tripped payload: %v", err) - } - if _, ok := wire["advanced"]; ok { - t.Fatalf("create DTO serialized an unset advanced policy: %s", encoded) - } -} - -func assertCapabilityPolicy(t *testing.T, advanced *AdvancedBoxOptionsDTO) { - t.Helper() - if advanced == nil || advanced.Capabilities == nil { - t.Fatal("advanced.capabilities was dropped during decoding") - } - if !reflect.DeepEqual(advanced.Capabilities.Add, []string{"SYS_ADMIN"}) { - t.Fatalf("unexpected advanced.capabilities.add: %v", advanced.Capabilities.Add) - } - if !reflect.DeepEqual(advanced.Capabilities.Drop, []string{"NET_RAW"}) { - t.Fatalf("unexpected advanced.capabilities.drop: %v", advanced.Capabilities.Drop) - } -} diff --git a/apps/runner/pkg/api/dto/info.go b/apps/runner/pkg/api/dto/info.go index f06418549..0b5feec9e 100644 --- a/apps/runner/pkg/api/dto/info.go +++ b/apps/runner/pkg/api/dto/info.go @@ -25,5 +25,4 @@ type RunnerInfoResponseDTO struct { ServiceHealth []*RunnerServiceInfo `json:"serviceHealth,omitempty"` Metrics *RunnerMetrics `json:"metrics,omitempty"` AppVersion string `json:"appVersion"` - Features []string `json:"features,omitempty"` } // @name RunnerInfoResponseDTO diff --git a/apps/runner/pkg/boxlite/client.go b/apps/runner/pkg/boxlite/client.go index fd0d81bc9..eed31e44d 100644 --- a/apps/runner/pkg/boxlite/client.go +++ b/apps/runner/pkg/boxlite/client.go @@ -247,21 +247,6 @@ func (c *Client) Create(ctx context.Context, boxDto dto.CreateBoxDTO) (string, s if len(boxDto.Entrypoint) > 0 { opts = append(opts, boxlite.WithEntrypoint(boxDto.Entrypoint...)) } - if boxDto.Advanced != nil && boxDto.Advanced.Capabilities != nil { - capabilities := boxDto.Advanced.Capabilities - advancedOptions, err := boxlite.NewAdvancedBoxOptions() - if err != nil { - return "", "", fmt.Errorf("create advanced box options: %w", err) - } - defer advancedOptions.Close() - if err := advancedOptions.SetCapabilities(boxlite.ContainerCapabilities{ - Add: capabilities.Add, - Drop: capabilities.Drop, - }); err != nil { - return "", "", fmt.Errorf("configure advanced container capabilities: %w", err) - } - opts = append(opts, boxlite.WithAdvancedOptions(advancedOptions)) - } volumeMounts, err := c.getVolumeMounts(ctx, boxDto.Volumes) if err != nil { diff --git a/apps/runner/pkg/boxlite/stubs.go b/apps/runner/pkg/boxlite/stubs.go index 072854221..c90253390 100644 --- a/apps/runner/pkg/boxlite/stubs.go +++ b/apps/runner/pkg/boxlite/stubs.go @@ -27,7 +27,6 @@ func (c *Client) RecoverBox(ctx context.Context, boxId string, recoverDto dto.Re MemoryQuota: recoverDto.MemoryQuota, StorageQuota: recoverDto.StorageQuota, Env: recoverDto.Env, - Advanced: recoverDto.Advanced, Volumes: recoverDto.Volumes, NetworkBlockAll: recoverDto.NetworkBlockAll, NetworkAllowList: recoverDto.NetworkAllowList, diff --git a/apps/runner/pkg/common/errors.go b/apps/runner/pkg/common/errors.go index 212772b75..f6e2b66ea 100644 --- a/apps/runner/pkg/common/errors.go +++ b/apps/runner/pkg/common/errors.go @@ -5,14 +5,12 @@ package common import ( - "errors" "fmt" "net/http" "strings" "time" "github.com/boxlite-ai/runner/internal/util" - boxlitesdk "github.com/boxlite-ai/boxlite/sdks/go" "github.com/containerd/errdefs" "github.com/gin-gonic/gin" @@ -20,18 +18,6 @@ import ( ) func HandlePossibleDockerError(ctx *gin.Context, err error) common_errors.ErrorResponse { - var boxliteErr *boxlitesdk.Error - if errors.As(err, &boxliteErr) && boxliteErr.Code == boxlitesdk.ErrInvalidArgument { - return common_errors.ErrorResponse{ - StatusCode: http.StatusBadRequest, - Message: fmt.Sprintf("bad request: %s", boxliteErr.Message), - Code: "BAD_REQUEST", - Timestamp: time.Now(), - Path: ctx.Request.URL.Path, - Method: ctx.Request.Method, - } - } - if errdefs.IsUnauthorized(err) || strings.Contains(err.Error(), "unauthorized") { return common_errors.ErrorResponse{ StatusCode: http.StatusUnauthorized, diff --git a/apps/runner/pkg/common/errors_test.go b/apps/runner/pkg/common/errors_test.go deleted file mode 100644 index b6913d322..000000000 --- a/apps/runner/pkg/common/errors_test.go +++ /dev/null @@ -1,40 +0,0 @@ -// Copyright 2025 BoxLite AI (originally Daytona Platforms Inc. -// Modified by BoxLite AI, 2025-2026 -// SPDX-License-Identifier: AGPL-3.0 - -package common - -import ( - "fmt" - "net/http" - "net/http/httptest" - "testing" - - boxlitesdk "github.com/boxlite-ai/boxlite/sdks/go" - "github.com/gin-gonic/gin" -) - -func TestHandlePossibleDockerErrorMapsBoxliteInvalidArgument(t *testing.T) { - gin.SetMode(gin.TestMode) - ctx, _ := gin.CreateTestContext(httptest.NewRecorder()) - ctx.Request = httptest.NewRequest(http.MethodPost, "/boxes", nil) - advanced, err := boxlitesdk.NewAdvancedBoxOptions() - if err != nil { - t.Fatalf("create advanced options: %v", err) - } - defer advanced.Close() - err = advanced.SetCapabilities(boxlitesdk.ContainerCapabilities{Add: []string{"NET-ADMIN"}}) - if err == nil { - t.Fatal("malformed capability must be rejected") - } - err = fmt.Errorf("configure advanced container capabilities: %w", err) - - response := HandlePossibleDockerError(ctx, err) - - if response.StatusCode != http.StatusBadRequest { - t.Fatalf("status = %d, want %d", response.StatusCode, http.StatusBadRequest) - } - if response.Code != "BAD_REQUEST" { - t.Fatalf("code = %q, want BAD_REQUEST", response.Code) - } -} diff --git a/apps/runner/pkg/runner/v2/executor/box_capabilities_test.go b/apps/runner/pkg/runner/v2/executor/box_capabilities_test.go deleted file mode 100644 index 55587bdd7..000000000 --- a/apps/runner/pkg/runner/v2/executor/box_capabilities_test.go +++ /dev/null @@ -1,97 +0,0 @@ -// Copyright 2026 BoxLite AI -// SPDX-License-Identifier: AGPL-3.0-only - -package executor - -import ( - "context" - "reflect" - "testing" - - apiclient "github.com/boxlite-ai/boxlite/libs/api-client-go" - "github.com/boxlite-ai/runner/pkg/api/dto" - "github.com/boxlite-ai/runner/pkg/backend" -) - -type capabilityCaptureBackend struct { - backend.BoxBackend - createRequest *dto.CreateBoxDTO - recoverRequest *dto.RecoverBoxDTO -} - -func (b *capabilityCaptureBackend) Create(_ context.Context, request dto.CreateBoxDTO) (string, string, error) { - b.createRequest = &request - return "box-1", "boxlite", nil -} - -func (b *capabilityCaptureBackend) RecoverBox(_ context.Context, _ string, request dto.RecoverBoxDTO) error { - b.recoverRequest = &request - return nil -} - -// A job payload travels through the queue as opaque JSON, so this covers the -// hop where a dropped policy would silently restore default privileges. -func TestExecuteJobPreservesCapabilityPolicy(t *testing.T) { - tests := []struct { - name string - jobType apiclient.JobType - payload string - capability func(*testing.T, *capabilityCaptureBackend) *dto.AdvancedBoxOptionsDTO - }{ - { - name: "create", - jobType: apiclient.JOBTYPE_CREATE_BOX, - payload: `{"id":"box-1","image":"alpine:latest","osUser":"boxlite","advanced":{"capabilities":{"add":["SYS_ADMIN"],"drop":["NET_RAW"]}}}`, - capability: func(t *testing.T, capture *capabilityCaptureBackend) *dto.AdvancedBoxOptionsDTO { - t.Helper() - if capture.createRequest == nil { - t.Fatal("create backend was not called") - } - return capture.createRequest.Advanced - }, - }, - { - name: "recover", - jobType: apiclient.JOBTYPE_RECOVER_BOX, - payload: `{"osUser":"boxlite","errorReason":"retry","advanced":{"capabilities":{"add":["SYS_ADMIN"],"drop":["NET_RAW"]}}}`, - capability: func(t *testing.T, capture *capabilityCaptureBackend) *dto.AdvancedBoxOptionsDTO { - t.Helper() - if capture.recoverRequest == nil { - t.Fatal("recover backend was not called") - } - return capture.recoverRequest.Advanced - }, - }, - } - - for _, test := range tests { - t.Run(test.name, func(t *testing.T) { - capture := &capabilityCaptureBackend{} - executor := &Executor{backend: capture} - job := apiclient.NewJob( - "job-1", - test.jobType, - apiclient.JOBSTATUS_PENDING, - "box", - "box-1", - "2026-01-01T00:00:00Z", - ) - job.Payload = &test.payload - - if _, err := executor.executeJob(context.Background(), job); err != nil { - t.Fatalf("execute job: %v", err) - } - - advanced := test.capability(t, capture) - if advanced == nil || advanced.Capabilities == nil { - t.Fatal("backend did not receive advanced capabilities") - } - if !reflect.DeepEqual(advanced.Capabilities.Add, []string{"SYS_ADMIN"}) { - t.Fatalf("unexpected advanced.capabilities.add: %v", advanced.Capabilities.Add) - } - if !reflect.DeepEqual(advanced.Capabilities.Drop, []string{"NET_RAW"}) { - t.Fatalf("unexpected advanced.capabilities.drop: %v", advanced.Capabilities.Drop) - } - }) - } -} diff --git a/apps/runner/pkg/runner/v2/healthcheck/healthcheck.go b/apps/runner/pkg/runner/v2/healthcheck/healthcheck.go index e5c62a6fe..a5397cd79 100644 --- a/apps/runner/pkg/runner/v2/healthcheck/healthcheck.go +++ b/apps/runner/pkg/runner/v2/healthcheck/healthcheck.go @@ -98,7 +98,6 @@ func (s *Service) sendHealthcheck(ctx context.Context) error { defer cancel() healthcheck := apiclient.NewRunnerHealthcheck(internal.Version) - healthcheck.SetFeatures([]string{internal.FeatureLinuxCapabilitiesV2}) healthcheck.SetDomain(s.domain) proxyUrl := fmt.Sprintf("http://%s:%d", s.domain, s.proxyPort) diff --git a/docs/architecture/container-capabilities.md b/docs/architecture/container-capabilities.md index af4a04ad4..e65017631 100644 --- a/docs/architecture/container-capabilities.md +++ b/docs/architecture/container-capabilities.md @@ -61,15 +61,18 @@ silently dropped: Ping before sending the nested policy. Guest rootfs images are cached per version and reused, so an older guest can outlive its release; it would decode the new field as unknown proto and drop it. -- The cloud control plane schedules capability-bearing boxes only onto runners - advertising `linux-capabilities-v2`, and re-checks it before start and - recovery. That token is the runner's own, independent of the guest version - the host checks. -A missing advertisement or too-old guest therefore fails closed. Boundaries -that do not carry a custom policy are unaffected: ordinary create, get, and -list keep working against any server version. Inspection does not report the -policy — it is create-time configuration, not box state. +A stale server or too-old guest therefore fails closed. Boundaries that do +not carry a custom policy are unaffected: ordinary create, get, and list keep +working against any server version. Inspection does not report the policy — +it is create-time configuration, not box state. + +The cloud control plane does not carry the policy yet. `boxlite serve` and the +reference server are the server side of the contract above. The hosted API +does not advertise `linux_capabilities_enabled`, so a BoxLite client refuses +to send a policy to it — the gate is on the client, not the server. A client +that skips that negotiation and posts `advanced` anyway has the field +dropped, because the hosted API does not reject unknown properties. Named `get_or_create` on the local runtime refuses to adopt an existing box whose capability policy differs from the requested one, so reuse cannot @@ -80,10 +83,9 @@ exports stay v3. A pre-capability importer accepts only up to v3, so it refuses the archive instead of dropping the policy and starting the box with wider privileges than the archive asked for. -Once a custom-policy box exists, do not roll the control plane back to a build -that predates these fields: such a build cannot preserve them while recreating -or recovering a box. Roll forward instead, and drain a runner before -downgrading it — a past advertisement cannot prove the binary is unchanged. +Once a custom-policy box exists, do not roll a server back to a build that +predates these fields: such a build cannot preserve them while recreating the +box. Roll forward instead. ## Project research diff --git a/docs/reference/README.md b/docs/reference/README.md index 25cba1caa..64c14de32 100644 --- a/docs/reference/README.md +++ b/docs/reference/README.md @@ -196,9 +196,8 @@ processes. Both lists default to empty, preserving BoxLite's Docker-compatible - Use `drop=["ALL"]` with explicit additions to construct a minimal set. - Malformed names are rejected at the API boundary. A well-formed name that the bundled guest runtime does not support is rejected during container initialization. -- Remote clients, hosts, and cloud runners negotiate support before creating - or starting a box; a custom policy is rejected rather than ignored when any - upgraded boundary is missing. +- A remote client and the host both check support before creating a box; a + custom policy is rejected rather than ignored when either side is too old. - The resolved set is applied to OCI bounding, effective, and permitted sets. Inheritable and ambient capabilities stay unset; they are not implied by `add`.