From ca9f12a84e486b625f10329ad2ca98dc52b1e089 Mon Sep 17 00:00:00 2001 From: SaladDay <1203511142@qq.com> Date: Mon, 28 Sep 2026 04:57:20 +0000 Subject: [PATCH 1/2] Add typed Core operation validation diagnostics --- CONTRIBUTING.md | 8 ++ contracts/agents-api/core-errors.md | 42 +++++++++- contracts/agents-api/v1/model_execution.go | 37 +++++++-- .../v1/model_provider_error_test.go | 45 +++++++++++ .../core_model_provider_validation_test.go | 81 +++++++++++++++++++ .../api/core_store_validation_test.go | 68 ++++++++++++++++ .../internal/api/core_validation_errors.go | 75 +++++++++++++++++ services/agents-api/internal/api/errors.go | 3 + .../internal/api/harness_model_providers.go | 5 +- ...odel_provider_public_compatibility_test.go | 34 ++++++++ .../agents-api/internal/sandbox/deployment.go | 36 +++++++-- .../sandbox/deployment_validation_test.go | 57 +++++++++++++ .../internal/store/admin_validation.go | 12 +++ .../internal/store/admin_validation_test.go | 66 +++++++++++++++ .../agents-api/internal/store/projects.go | 2 +- .../internal/store/runtime_nodes.go | 10 ++- .../store/sandbox_deployment_mutations.go | 6 +- .../internal/store/sandbox_specification.go | 11 ++- 18 files changed, 574 insertions(+), 24 deletions(-) create mode 100644 contracts/agents-api/v1/model_provider_error_test.go create mode 100644 services/agents-api/internal/api/core_model_provider_validation_test.go create mode 100644 services/agents-api/internal/api/core_store_validation_test.go create mode 100644 services/agents-api/internal/api/core_validation_errors.go create mode 100644 services/agents-api/internal/api/model_provider_public_compatibility_test.go create mode 100644 services/agents-api/internal/sandbox/deployment_validation_test.go create mode 100644 services/agents-api/internal/store/admin_validation.go create mode 100644 services/agents-api/internal/store/admin_validation_test.go diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 8dea4e34c..2272dac20 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -3797,3 +3797,11 @@ code. Include only safe Core-owned facts; never pass submitted values, secrets, native text or provider bodies. Invalid/empty details are omitted. Preserve the public and machine error serializers, observer callbacks and streaming interfaces. The Core client ignores malformed optional details and never retries a mutation. + +Core operation validators preserve the original error text, sentinel identity, +and validation precedence. Package-owned typed errors carry fixed field metadata; +only the marked Core error mapper translates it to operation codes and safe +bounds/catalog details. Preserve Project/key rune limits and node byte limits +separately. Keep sandbox validation metadata through its existing store wrapper +without changing transaction or provider authority. Public Session provider +validation remains byte-compatible; cover it with handler-level golden responses. diff --git a/contracts/agents-api/core-errors.md b/contracts/agents-api/core-errors.md index 2172e3b95..3081d305e 100644 --- a/contracts/agents-api/core-errors.md +++ b/contracts/agents-api/core-errors.md @@ -16,8 +16,8 @@ strings, finite numbers, booleans, null or arrays of strings (including empty arrays). It contains documented Core-owned facts, never submitted names, URLs, keys, echoed request values, native error text or provider response bodies. Each operation that adds details must document its exact keys alongside its error -code. This foundation does not add operation-specific validators, diagnostics -endpoints or new details to existing operations. +code. Operation-specific validation details are listed below; this contract does not +add diagnostics endpoints. The typed Go `CoreErrorDetails` values have string, number, boolean, null and string-array constructors. `writeCoreError` emits them only through the marked @@ -57,7 +57,7 @@ path checks and the no-retry rule are unchanged. Existing operation-specific codes remain documented in the [administrator contract](admin-api.md), [sandbox deployment contract](sandbox-deployment.md), [executor credential contract](environment-executor-credentials.md) and related -resource contracts. This change does not reclassify their validation failures. +resource contracts. The following validators refine Core operation failures only. ## E2B online changes @@ -75,3 +75,39 @@ Missing or unsettled Create receipts are uncertainty, never evidence of a differ team or released compute. The typed client projects these codes to fixed local messages. It also projects `409 sandbox_configuration_error` to fixed public-URL guidance, even when a PUT omitted its key; arbitrary upstream text is never echoed. + +## Operation validation + +All entries below return HTTP 400 with type `invalid_request_error`. Missing, +malformed or incorrectly typed model-provider bundles use `invalid_model_provider` +before field validation. JSON body parsing retains its existing errors. Other +malformed administration requests retain `invalid_request`. + +| Code | Param | Details | Meaning | +| --- | --- | --- | --- | +| `invalid_name` | `name` | `max_length`: 128 for Projects/nodes, 80 for Project keys | Name failed the resource's existing validator | +| `invalid_node_capacity` | `max_active` or `max_retained` | `min`: 1, `max`: 1000000 | Capacity is invalid; retained capacity must also be at least active capacity | +| `invalid_model_provider` | null | omitted | A complete model-provider bundle is required | +| `model_provider_base_url_invalid` | `base_url` | omitted | Requires HTTPS without credentials, query or fragment | +| `model_provider_protocol_unsupported` | `protocol` | `harness` and `allowed_protocols`, from the build's adapter catalog | Protocol is unknown or unsupported by the selected harness | +| `model_provider_api_key_invalid` | `api_key` | `max_length`: 16384 | Key is empty, too long or contains a prohibited character | +| `model_provider_token_limits_invalid` | `context_window` or `max_output_tokens` | omitted | Limits are invalid or required positive limits are missing | +| `invalid_sandbox_configuration` | `resources.cpus` | `min`: 1, `max`: 255 | CPU count is outside the supported bounds | +| `invalid_sandbox_configuration` | `resources.memory_mib` | `min`: 512, `max`: 1048576 | Memory is outside the supported bounds | +| `invalid_sandbox_configuration` | `resources.root_disk_mib` or `resources.environment_disk_mib` | `min`: 1024 for microsandbox; `min`: 0, `max`: 0 for Docker/E2B | Disk capacity is missing or unsupported by the provider | +| `invalid_sandbox_configuration` | `runtime` | omitted | Runtime release is missing, mutable, invalid or prohibited for E2B | + +Bounds describe validation constants, never submitted values. Node names retain +their existing byte limit and character rules; Project/key names retain their +trimmed Unicode character limit and control-character rules. No name rules are +widened or unified. Numeric checks retain their existing order, including +provider-dependent retained capacity validation inside the existing transaction. +Model-provider checks retain URL, protocol, key, general limits, harness protocol, +then required harness limits precedence. Resource checks retain CPU, memory, disk, +then Runtime precedence. + +Shared validators preserve their original error strings and sentinel identity. +Only the marked Core router maps their typed field metadata to this catalog; +public `/v1` and machine routes retain their previous complete error bodies. +Unknown sandbox providers retain the existing untyped error. E2B provider errors +above retain their fixed redaction contract with no echoed template or key. diff --git a/contracts/agents-api/v1/model_execution.go b/contracts/agents-api/v1/model_execution.go index 4edf6dd27..725e2b960 100644 --- a/contracts/agents-api/v1/model_execution.go +++ b/contracts/agents-api/v1/model_execution.go @@ -1,13 +1,21 @@ package v1 import ( - "errors" "strings" "github.com/MiniMax-AI-Dev/parsar/internal/harnessconfig" "github.com/MiniMax-AI-Dev/parsar/internal/harnessconfig/builtin" ) +// ModelProviderError preserves the shared validation message while allowing Core +// administration to identify a field. It contains no submitted values. +type ModelProviderError struct { + Code, Param string + message string +} + +func (e *ModelProviderError) Error() string { return e.message } + // SessionExecutionInput is a write-only execution extension, not a provider resource. type SessionExecutionInput struct { ModelProvider *ModelProviderInput `json:"model_provider,omitempty"` @@ -27,19 +35,23 @@ func (p *ModelProviderInput) Validate() error { func (p *ModelProviderInput) validate(registry harnessconfig.Registry) error { if p == nil { - return errors.New("model_provider is required") + return &ModelProviderError{Code: "invalid_model_provider", Param: "", message: "model_provider is required"} } if !validModelProviderBaseURL(p.BaseURL) { - return errors.New("model provider requires an HTTPS base_url without credentials, query or fragment") + return &ModelProviderError{Code: "model_provider_base_url_invalid", Param: "base_url", message: "model provider requires an HTTPS base_url without credentials, query or fragment"} } if !registry.SupportsProtocol(p.Protocol) { - return errors.New("unsupported model provider protocol") + return &ModelProviderError{Code: "model_provider_protocol_unsupported", Param: "protocol", message: "unsupported model provider protocol"} } if strings.TrimSpace(p.APIKey) == "" || len(p.APIKey) > 16384 || strings.ContainsAny(p.APIKey, "\x00\r\n") { - return errors.New("invalid model provider API key") + return &ModelProviderError{Code: "model_provider_api_key_invalid", Param: "api_key", message: "invalid model provider API key"} } if p.ContextWindow < 0 || p.MaxOutputTokens < 0 || (p.MaxOutputTokens > p.ContextWindow) { - return errors.New("invalid model token limits") + param := "max_output_tokens" + if p.ContextWindow < 0 { + param = "context_window" + } + return &ModelProviderError{Code: "model_provider_token_limits_invalid", Param: param, message: "invalid model token limits"} } return nil } @@ -54,7 +66,18 @@ func (p *ModelProviderInput) ValidateHarnessWithRegistry(harness string, registr if err := p.validate(registry); err != nil { return err } - return p.SafeView().ValidateHarnessWithRegistry(harness, registry) + configuration, _ := registry.Lookup(harness) + if err := configuration.ValidateProtocol(p.Protocol); err != nil { + return &ModelProviderError{Code: "model_provider_protocol_unsupported", Param: "protocol", message: err.Error()} + } + if err := configuration.Validate(p.Protocol, p.ContextWindow, p.MaxOutputTokens); err != nil { + param := "max_output_tokens" + if p.ContextWindow <= 0 { + param = "context_window" + } + return &ModelProviderError{Code: "model_provider_token_limits_invalid", Param: param, message: err.Error()} + } + return nil } func ValidateModelProtocol(protocol, harness string) error { diff --git a/contracts/agents-api/v1/model_provider_error_test.go b/contracts/agents-api/v1/model_provider_error_test.go new file mode 100644 index 000000000..e94829faf --- /dev/null +++ b/contracts/agents-api/v1/model_provider_error_test.go @@ -0,0 +1,45 @@ +package v1 + +import ( + "errors" + "testing" +) + +func TestModelProviderErrorMessagesAndPrecedence(t *testing.T) { + valid := ModelProviderInput{Protocol: "responses", BaseURL: "https://example.test", APIKey: "private-key"} + for _, tc := range []struct { + name, harness, code, param, message string + change func(*ModelProviderInput) + }{ + {"url first", "codex", "model_provider_base_url_invalid", "base_url", "model provider requires an HTTPS base_url without credentials, query or fragment", func(p *ModelProviderInput) { + p.BaseURL = "http://private.example" + p.Protocol = "private" + p.APIKey = "" + }}, + {"protocol before key", "codex", "model_provider_protocol_unsupported", "protocol", "unsupported model provider protocol", func(p *ModelProviderInput) { p.Protocol = "private"; p.APIKey = "" }}, + {"key before harness", "claude_sdk", "model_provider_api_key_invalid", "api_key", "invalid model provider API key", func(p *ModelProviderInput) { p.APIKey = "" }}, + {"context before output", "codex", "model_provider_token_limits_invalid", "context_window", "invalid model token limits", func(p *ModelProviderInput) { p.ContextWindow = -1; p.MaxOutputTokens = -2 }}, + {"output", "codex", "model_provider_token_limits_invalid", "max_output_tokens", "invalid model token limits", func(p *ModelProviderInput) { p.MaxOutputTokens = 1 }}, + {"harness protocol", "claude_sdk", "model_provider_protocol_unsupported", "protocol", "selected harness does not support this model provider protocol", func(p *ModelProviderInput) {}}, + {"required context", "mcode", "model_provider_token_limits_invalid", "context_window", "selected harness requires positive model context_window and max_output_tokens", func(p *ModelProviderInput) { p.Protocol = "anthropic" }}, + {"required output", "mcode", "model_provider_token_limits_invalid", "max_output_tokens", "selected harness requires positive model context_window and max_output_tokens", func(p *ModelProviderInput) { p.Protocol = "anthropic"; p.ContextWindow = 10 }}, + } { + t.Run(tc.name, func(t *testing.T) { + p := valid + tc.change(&p) + err := p.ValidateHarness(tc.harness) + var field *ModelProviderError + if !errors.As(err, &field) || field.Code != tc.code || field.Param != tc.param || err.Error() != tc.message { + t.Fatalf("wrong typed error: %#v", err) + } + }) + } + var missing *ModelProviderInput + var field *ModelProviderError + if err := missing.Validate(); !errors.As(err, &field) || field.Code != "invalid_model_provider" || field.Param != "" || err.Error() != "model_provider is required" { + t.Fatal(err) + } + if err := valid.ValidateHarness("codex"); err != nil { + t.Fatal(err) + } +} diff --git a/services/agents-api/internal/api/core_model_provider_validation_test.go b/services/agents-api/internal/api/core_model_provider_validation_test.go new file mode 100644 index 000000000..a5fb172c9 --- /dev/null +++ b/services/agents-api/internal/api/core_model_provider_validation_test.go @@ -0,0 +1,81 @@ +package api + +import ( + "context" + "encoding/json" + "net/http" + "reflect" + "strings" + "testing" + + v1 "github.com/MiniMax-AI-Dev/parsar/contracts/agents-api/v1" + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" +) + +type coreProviderValidationStore struct { + ResourceStore + writes int +} + +func (s *coreProviderValidationStore) ListDeploymentModelProviders(context.Context) ([]store.DeploymentModelProvider, error) { + return nil, nil +} +func (s *coreProviderValidationStore) DeleteDeploymentModelProvider(context.Context, string) error { + return nil +} +func (s *coreProviderValidationStore) SetDeploymentModelProvider(context.Context, string, v1.ModelProviderInput) (store.DeploymentModelProvider, error) { + s.writes++ + return store.DeploymentModelProvider{}, nil +} + +func TestCoreModelProviderValidationFields(t *testing.T) { + s := &coreProviderValidationStore{} + h, _, _ := adminTestHandler(t, func(h *Handler) { h.store = s }) + for _, tc := range []struct { + name, harness, body, code, param string + details map[string]any + }{ + {"bundle", "codex", `{"api_key":"private-key"}`, "invalid_model_provider", "", nil}, + {"negative shape", "codex", `{"protocol":"responses","base_url":"http://private-url","api_key":"private-key","context_window":-1}`, "invalid_model_provider", "", nil}, + {"url first", "codex", `{"protocol":"private-protocol","base_url":"http://private-url","api_key":"private-key"}`, "model_provider_base_url_invalid", "base_url", nil}, + {"protocol", "codex", `{"protocol":"private-protocol","base_url":"https://example.test","api_key":"private-key"}`, "model_provider_protocol_unsupported", "protocol", map[string]any{"harness": "codex", "allowed_protocols": []any{"responses"}}}, + {"harness protocol", "claude_sdk", `{"protocol":"responses","base_url":"https://example.test","api_key":"private-key"}`, "model_provider_protocol_unsupported", "protocol", map[string]any{"harness": "claude_sdk", "allowed_protocols": []any{"anthropic"}}}, + {"key", "codex", `{"protocol":"responses","base_url":"https://example.test","api_key":"private-key\n"}`, "model_provider_api_key_invalid", "api_key", map[string]any{"max_length": float64(16384)}}, + {"output", "codex", `{"protocol":"responses","base_url":"https://example.test","api_key":"private-key","context_window":1,"max_output_tokens":2}`, "model_provider_token_limits_invalid", "max_output_tokens", nil}, + {"context required", "mcode", `{"protocol":"anthropic","base_url":"https://example.test","api_key":"private-key"}`, "model_provider_token_limits_invalid", "context_window", nil}, + {"output required", "mcode", `{"protocol":"anthropic","base_url":"https://example.test","api_key":"private-key","context_window":2}`, "model_provider_token_limits_invalid", "max_output_tokens", nil}, + } { + t.Run(tc.name, func(t *testing.T) { + out := projectKeyHTTP(h, http.MethodPut, "/core/v1/harnesses/"+tc.harness+"/model-provider", "admin", tc.body) + var body struct { + Error struct { + Code string + Param *string + Details map[string]any + } + } + if err := json.Unmarshal(out.Body.Bytes(), &body); err != nil { + t.Fatal(err) + } + param := "" + if body.Error.Param != nil { + param = *body.Error.Param + } + if out.Code != 400 || body.Error.Code != tc.code || param != tc.param || !reflect.DeepEqual(body.Error.Details, tc.details) || strings.Contains(out.Body.String(), "private-") { + t.Fatalf("unexpected diagnostics: %d %s", out.Code, out.Body) + } + }) + } + if s.writes != 0 { + t.Fatal("invalid input reached storage") + } + for _, tc := range []struct { + harness, token string + status int + }{{"codex", "", 401}, {"private-harness", "admin", 404}} { + out := projectKeyHTTP(h, http.MethodPut, "/core/v1/harnesses/"+tc.harness+"/model-provider", tc.token, `{}`) + if out.Code != tc.status || strings.Contains(out.Body.String(), "private-") { + t.Fatal("admission order changed", out.Code, out.Body) + } + } +} diff --git a/services/agents-api/internal/api/core_store_validation_test.go b/services/agents-api/internal/api/core_store_validation_test.go new file mode 100644 index 000000000..153302f2c --- /dev/null +++ b/services/agents-api/internal/api/core_store_validation_test.go @@ -0,0 +1,68 @@ +package api + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "reflect" + "strings" + "testing" + + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/sandbox" + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" +) + +func TestCoreStoreValidationFieldsAndPublicFallback(t *testing.T) { + s := &store.Store{} + _, nameErr := s.CreateProject(context.Background(), managementProjectID, strings.Repeat("private-name", 20)) + capacityErr := s.UpdateRuntimeNode(context.Background(), managementProjectID, store.RuntimeNodeUpdate{Name: "node", MaxActive: 0}) + _, resourceErr := store.SandboxSetupForSelection(managementProjectID, store.SandboxDeploymentSetupRequest{Provider: "docker", DeploymentSpec: sandbox.DeploymentSpec{}}) + _, runtimeErr := store.SandboxSetupForSelection(managementProjectID, store.SandboxDeploymentSetupRequest{Provider: "docker", DeploymentSpec: sandbox.DeploymentSpec{Resources: sandbox.Resources{CPUs: 1, MemoryMiB: 512}}}) + for _, tc := range []struct { + err error + code, param string + details map[string]any + publicMessage, publicCode string + }{ + {nameErr, "invalid_name", "name", map[string]any{"max_length": float64(128)}, "Invalid resource identifier or request limits.", "invalid_request"}, + {capacityErr, "invalid_node_capacity", "max_active", map[string]any{"min": float64(1), "max": float64(1000000)}, "Invalid resource identifier or request limits.", "invalid_request"}, + {resourceErr, "invalid_sandbox_configuration", "resources.cpus", map[string]any{"min": float64(1), "max": float64(255)}, "invalid sandbox configuration: cpus must be 1..255 and memory_mib must be 512..1048576", "invalid_sandbox_configuration"}, + {runtimeErr, "invalid_sandbox_configuration", "runtime", nil, "invalid sandbox configuration: managed nodes require a pinned Runtime release", "invalid_sandbox_configuration"}, + } { + if tc.err == nil { + t.Fatal("missing validator error") + } + for _, core := range []bool{false, true} { + handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { writeStoreError(w, r, fmt.Errorf("wrapped: %w", tc.err)) })) + if core { + handler = coreErrorResponses(handler) + } + out := httptest.NewRecorder() + handler.ServeHTTP(out, httptest.NewRequest("POST", "/core/v1/test", nil)) + if out.Code != 400 || strings.Contains(out.Body.String(), "private-name") { + t.Fatal(out.Code, out.Body) + } + if !core { + golden := `{"error":{"message":"` + tc.publicMessage + `","type":"invalid_request_error","code":"` + tc.publicCode + `","param":null}}` + "\n" + if out.Body.String() != golden { + t.Fatal("public/machine fallback changed", out.Body) + } + continue + } + var body struct { + Error struct { + Code, Param string + Details map[string]any + } + } + if err := json.Unmarshal(out.Body.Bytes(), &body); err != nil { + t.Fatal(err) + } + if body.Error.Code != tc.code || body.Error.Param != tc.param || !reflect.DeepEqual(body.Error.Details, tc.details) { + t.Fatal(out.Body) + } + } + } +} diff --git a/services/agents-api/internal/api/core_validation_errors.go b/services/agents-api/internal/api/core_validation_errors.go new file mode 100644 index 000000000..acac4bad7 --- /dev/null +++ b/services/agents-api/internal/api/core_validation_errors.go @@ -0,0 +1,75 @@ +package api + +import ( + "errors" + "net/http" + + v1 "github.com/MiniMax-AI-Dev/parsar/contracts/agents-api/v1" + "github.com/MiniMax-AI-Dev/parsar/internal/harnessconfig/builtin" + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" +) + +// writeCoreValidationError is deliberately gated by the router marker. Shared +// store validators must retain the public and machine routes' existing errors. +func writeCoreValidationError(w http.ResponseWriter, err error) bool { + if !isCoreErrorWriter(w) { + return false + } + var field *store.AdminValidationError + var configuration *store.SandboxConfigurationError + switch { + case errors.As(err, &field): + details := CoreErrorDetails{"max_length": CoreErrorNumber(float64(field.MaxLength))} + message := "The name exceeds its length limit or contains invalid characters." + if field.Code == "invalid_node_capacity" { + details = CoreErrorDetails{"min": CoreErrorNumber(1), "max": CoreErrorNumber(1000000)} + message = "Node capacity must be positive, at most 1000000, and max_retained must be at least max_active." + } + writeCoreError(w, http.StatusBadRequest, field.Code, message, details, field.Param) + return true + case errors.As(err, &configuration) && configuration.Validation != nil: + field := configuration.Validation + details := CoreErrorDetails{} + if field.Min != nil { + details["min"] = CoreErrorNumber(float64(*field.Min)) + } + if field.Max != nil { + details["max"] = CoreErrorNumber(float64(*field.Max)) + } + writeCoreError(w, http.StatusBadRequest, "invalid_sandbox_configuration", configuration.Message, details, field.Param) + return true + } + return false +} + +func writeCoreModelProviderError(w http.ResponseWriter, err error, harness string) bool { + if !isCoreErrorWriter(w) { + return false + } + var field *v1.ModelProviderError + if !errors.As(err, &field) { + return false + } + details := CoreErrorDetails{} + if field.Code == "model_provider_api_key_invalid" { + details["max_length"] = CoreErrorNumber(16384) + } + if field.Code == "model_provider_protocol_unsupported" { + // Only adapter-owned catalog values may enter details, never a submitted + // protocol or an unknown path harness. + if configuration, known := builtin.Registry().Lookup(harness); known { + protocols := make([]string, 0, len(configuration.Providers)) + for _, provider := range configuration.Providers { + protocols = append(protocols, provider.Protocol) + } + details["harness"] = CoreErrorString(harness) + details["allowed_protocols"] = CoreErrorStrings(protocols...) + } + } + var param []string + if field.Param != "" { + param = []string{field.Param} + } + writeCoreError(w, http.StatusBadRequest, field.Code, field.Error(), details, param...) + return true +} diff --git a/services/agents-api/internal/api/errors.go b/services/agents-api/internal/api/errors.go index bfb714ecd..60047d0ba 100644 --- a/services/agents-api/internal/api/errors.go +++ b/services/agents-api/internal/api/errors.go @@ -93,6 +93,9 @@ func writeFieldError(w http.ResponseWriter, err error) bool { } func writeStoreError(w http.ResponseWriter, r *http.Request, err error, notFoundParam ...string) { + if writeCoreValidationError(w, err) { + return + } var cursor *store.InvalidCursorError var selection *store.MCPCredentialSelectionError var sandboxConfiguration *store.SandboxConfigurationError diff --git a/services/agents-api/internal/api/harness_model_providers.go b/services/agents-api/internal/api/harness_model_providers.go index e5ccf9bb4..f912c970b 100644 --- a/services/agents-api/internal/api/harness_model_providers.go +++ b/services/agents-api/internal/api/harness_model_providers.go @@ -163,10 +163,13 @@ func (h *Handler) setHarnessModelProvider(w http.ResponseWriter, r *http.Request var input v1.ModelProviderInput // Neither message echoes submitted values, which may include the key. if checkValue("model_provider", raw, harnessModelProviderShape) != nil || decodeInputObject(raw, &input, "protocol", "base_url", "api_key", "context_window", "max_output_tokens") != nil { - writeError(w, http.StatusBadRequest, "invalid_request_error", "The body must be a complete model provider: protocol, base_url, api_key and optional nonnegative context_window and max_output_tokens.") + writeError(w, http.StatusBadRequest, "invalid_model_provider", "The body must be a complete model provider: protocol, base_url, api_key and optional nonnegative context_window and max_output_tokens.") return } if err := input.ValidateHarness(harness); err != nil { + if writeCoreModelProviderError(w, err, harness) { + return + } writeError(w, http.StatusBadRequest, "invalid_request_error", err.Error()) return } diff --git a/services/agents-api/internal/api/model_provider_public_compatibility_test.go b/services/agents-api/internal/api/model_provider_public_compatibility_test.go new file mode 100644 index 000000000..d145e33a7 --- /dev/null +++ b/services/agents-api/internal/api/model_provider_public_compatibility_test.go @@ -0,0 +1,34 @@ +package api + +import ( + "net/http" + "testing" +) + +func TestModelProviderPublicValidationCompatibility(t *testing.T) { + for _, tc := range []struct{ name, provider string }{ + {"url", `{"protocol":"responses","base_url":"http://private-url.example","api_key":"private-key"}`}, + {"protocol", `{"protocol":"private-protocol","base_url":"https://example.test","api_key":"private-key"}`}, + {"key", `{"protocol":"responses","base_url":"https://example.test","api_key":""}`}, + {"limits", `{"protocol":"responses","base_url":"https://example.test","api_key":"private-key","context_window":1,"max_output_tokens":2}`}, + {"harness", `{"protocol":"anthropic","base_url":"https://example.test","api_key":"private-key"}`}, + } { + t.Run(tc.name, func(t *testing.T) { + h, _, _ := testHandler(t) + body := `{"agent":{"model":"fixture"},"environment":{"type":"openai_hosted"},"input":"hello","x_agents_core":{"model_provider":` + tc.provider + `}}` + out := credentialRequest(h, http.MethodPost, "/v1/agents/sessions", body) + messages := map[string]string{ + "url": "model provider requires an HTTPS base_url without credentials, query or fragment", + "protocol": "unsupported model provider protocol", "key": "invalid model provider API key", + "limits": "invalid model token limits", "harness": "selected harness does not support this model provider protocol", + } + golden := `{"error":{"message":"` + messages[tc.name] + `","type":"invalid_request_error","code":"unsupported_or_invalid_configuration","param":null}}` + "\n" + if out.Body.String() != golden { + t.Fatalf("public bytes changed: %s", out.Body) + } + if out.Code != 400 { + t.Fatalf("unexpected status: %d %s", out.Code, out.Body) + } + }) + } +} diff --git a/services/agents-api/internal/sandbox/deployment.go b/services/agents-api/internal/sandbox/deployment.go index 439d9ccc2..eeb8f44a3 100644 --- a/services/agents-api/internal/sandbox/deployment.go +++ b/services/agents-api/internal/sandbox/deployment.go @@ -8,6 +8,18 @@ import ( "strings" ) +// ValidationError preserves the sandbox error text and identity while identifying +// a fixed configuration field and, for numeric limits, fixed inclusive bounds. +type ValidationError struct { + Param string + Min, Max *uint32 + Message string +} + +func (e *ValidationError) Error() string { return e.Message } +func (e *ValidationError) Unwrap() error { return ErrInvalid } +func validationBound(value uint32) *uint32 { return &value } + // Resources describes one managed sandbox, independently of node concurrency. // Disk bounds are available only where the native provider enforces them. type Resources struct { @@ -19,16 +31,28 @@ type Resources struct { func (r Resources) Validate(provider string) error { if r.CPUs == 0 || r.CPUs > 255 || r.MemoryMiB < 512 || r.MemoryMiB > 1048576 { - return fmt.Errorf("%w: cpus must be 1..255 and memory_mib must be 512..1048576", ErrInvalid) + field, min, max := "resources.cpus", uint32(1), uint32(255) + if r.CPUs > 0 && r.CPUs <= 255 { + field, min, max = "resources.memory_mib", 512, 1048576 + } + return &ValidationError{Param: field, Min: &min, Max: &max, Message: fmt.Sprintf("%s: cpus must be 1..255 and memory_mib must be 512..1048576", ErrInvalid)} } switch provider { case "microsandbox": if r.RootDiskMiB < 1024 || r.EnvironmentDiskMiB < 1024 { - return fmt.Errorf("%w: microsandbox requires root_disk_mib and environment_disk_mib of at least 1024 MiB", ErrInvalid) + field := "resources.root_disk_mib" + if r.RootDiskMiB >= 1024 { + field = "resources.environment_disk_mib" + } + return &ValidationError{Param: field, Min: validationBound(1024), Message: fmt.Sprintf("%s: microsandbox requires root_disk_mib and environment_disk_mib of at least 1024 MiB", ErrInvalid)} } case "docker", "e2b": if r.RootDiskMiB != 0 || r.EnvironmentDiskMiB != 0 { - return fmt.Errorf("%w: %s does not support independent disk capacity limits", ErrInvalid, provider) + field := "resources.root_disk_mib" + if r.RootDiskMiB == 0 { + field = "resources.environment_disk_mib" + } + return &ValidationError{Param: field, Min: validationBound(0), Max: validationBound(0), Message: fmt.Sprintf("%s: %s does not support independent disk capacity limits", ErrInvalid, provider)} } default: return fmt.Errorf("%w: unsupported sandbox provider", ErrInvalid) @@ -58,7 +82,7 @@ func (r RuntimeRelease) Validate() error { !strings.HasPrefix(r.ImageManifestDigest, "sha256:") || !lowerHex(strings.TrimPrefix(r.ImageManifestDigest, "sha256:"), 32) || !strings.HasPrefix(r.MicrosandboxRef, "oac-runtime@sha256:") || !lowerHex(strings.TrimPrefix(r.MicrosandboxRef, "oac-runtime@sha256:"), 32) || !lowerHex(r.RuntimeSHA256, 32) || !lowerHex(r.FirmwareSHA256, 32) { - return fmt.Errorf("%w: Runtime must reference one immutable distribution", ErrInvalid) + return &ValidationError{Param: "runtime", Message: fmt.Sprintf("%s: Runtime must reference one immutable distribution", ErrInvalid)} } return nil } @@ -74,12 +98,12 @@ func (s DeploymentSpec) Validate(provider string) error { } if provider == "e2b" { if s.Runtime != nil { - return fmt.Errorf("%w: E2B Runtime is selected by its immutable template build", ErrInvalid) + return &ValidationError{Param: "runtime", Message: fmt.Sprintf("%s: E2B Runtime is selected by its immutable template build", ErrInvalid)} } return nil } if s.Runtime == nil { - return fmt.Errorf("%w: managed nodes require a pinned Runtime release", ErrInvalid) + return &ValidationError{Param: "runtime", Message: fmt.Sprintf("%s: managed nodes require a pinned Runtime release", ErrInvalid)} } return s.Runtime.Validate() } diff --git a/services/agents-api/internal/sandbox/deployment_validation_test.go b/services/agents-api/internal/sandbox/deployment_validation_test.go new file mode 100644 index 000000000..07085b96d --- /dev/null +++ b/services/agents-api/internal/sandbox/deployment_validation_test.go @@ -0,0 +1,57 @@ +package sandbox + +import ( + "errors" + "testing" +) + +func TestDeploymentValidationFieldsPreserveMessages(t *testing.T) { + for _, tc := range []struct { + provider string + resources Resources + param, message string + min, max *uint32 + }{ + {"docker", Resources{}, "resources.cpus", "cpus must be 1..255 and memory_mib must be 512..1048576", validationBound(1), validationBound(255)}, + {"docker", Resources{CPUs: 1, MemoryMiB: 511}, "resources.memory_mib", "cpus must be 1..255 and memory_mib must be 512..1048576", validationBound(512), validationBound(1048576)}, + {"microsandbox", Resources{CPUs: 1, MemoryMiB: 512}, "resources.root_disk_mib", "microsandbox requires root_disk_mib and environment_disk_mib of at least 1024 MiB", validationBound(1024), nil}, + {"microsandbox", Resources{CPUs: 1, MemoryMiB: 512, RootDiskMiB: 1024}, "resources.environment_disk_mib", "microsandbox requires root_disk_mib and environment_disk_mib of at least 1024 MiB", validationBound(1024), nil}, + {"docker", Resources{CPUs: 1, MemoryMiB: 512, RootDiskMiB: 1}, "resources.root_disk_mib", "docker does not support independent disk capacity limits", validationBound(0), validationBound(0)}, + {"e2b", Resources{CPUs: 1, MemoryMiB: 512, EnvironmentDiskMiB: 1}, "resources.environment_disk_mib", "e2b does not support independent disk capacity limits", validationBound(0), validationBound(0)}, + } { + err := tc.resources.Validate(tc.provider) + var field *ValidationError + if !errors.As(err, &field) || !errors.Is(err, ErrInvalid) || err.Error() != ErrInvalid.Error()+": "+tc.message || field.Param != tc.param { + t.Fatalf("wrong error: %#v", err) + } + for i, bound := range []*uint32{field.Min, field.Max} { + want := []*uint32{tc.min, tc.max}[i] + if (bound == nil) != (want == nil) || (bound != nil && *bound != *want) { + t.Fatal("wrong fixed bounds", field) + } + } + } + for _, tc := range []struct { + provider string + runtime *RuntimeRelease + message string + }{ + {"docker", nil, "managed nodes require a pinned Runtime release"}, + {"docker", &RuntimeRelease{}, "Runtime must reference one immutable distribution"}, + {"e2b", &RuntimeRelease{}, "E2B Runtime is selected by its immutable template build"}, + } { + err := (DeploymentSpec{Resources: Resources{CPUs: 1, MemoryMiB: 512}, Runtime: tc.runtime}).Validate(tc.provider) + var field *ValidationError + if !errors.As(err, &field) || field.Param != "runtime" || err.Error() != ErrInvalid.Error()+": "+tc.message || !errors.Is(err, ErrInvalid) { + t.Fatal(err) + } + } + if err := (Resources{CPUs: 255, MemoryMiB: 1048576}).Validate("docker"); err != nil { + t.Fatal(err) + } + var field *ValidationError + err := (Resources{CPUs: 1, MemoryMiB: 512}).Validate("private-provider") + if errors.As(err, &field) || err.Error() != ErrInvalid.Error()+": unsupported sandbox provider" { + t.Fatal("unknown provider reclassified", err) + } +} diff --git a/services/agents-api/internal/store/admin_validation.go b/services/agents-api/internal/store/admin_validation.go new file mode 100644 index 000000000..00e819a7a --- /dev/null +++ b/services/agents-api/internal/store/admin_validation.go @@ -0,0 +1,12 @@ +package store + +// AdminValidationError adds field identity without changing the store error text +// or ErrInvalidInput classification. Bounds are fixed validator constants. +type AdminValidationError struct { + Code, Param string + MaxLength int + message string +} + +func (e *AdminValidationError) Error() string { return e.message } +func (e *AdminValidationError) Unwrap() error { return ErrInvalidInput } diff --git a/services/agents-api/internal/store/admin_validation_test.go b/services/agents-api/internal/store/admin_validation_test.go new file mode 100644 index 000000000..789e0aa56 --- /dev/null +++ b/services/agents-api/internal/store/admin_validation_test.go @@ -0,0 +1,66 @@ +package store + +import ( + "errors" + "fmt" + "strings" + "testing" + + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/sandbox" +) + +func TestAdminNameValidationIdentityAndBoundaries(t *testing.T) { + for _, max := range []int{80, 128} { + for _, name := range []string{"", " ", "private-name\t", strings.Repeat("a", max+1), string([]byte{0xff})} { + _, err := adminResourceName(name, max) + var field *AdminValidationError + if !errors.As(err, &field) || !errors.Is(err, ErrInvalidInput) || field.Code != "invalid_name" || field.Param != "name" || field.MaxLength != max || err.Error() != fmt.Sprintf("%s: name must contain 1–%d characters without controls", ErrInvalidInput, max) { + t.Fatalf("wrong name error: %#v", err) + } + } + if got, err := adminResourceName(" "+strings.Repeat("界", max)+" ", max); err != nil || got != strings.Repeat("界", max) { + t.Fatal("rune/trim semantics changed", err) + } + } +} + +func TestRuntimeNodeValidationIdentityAndPrecedence(t *testing.T) { + for _, tc := range []struct { + name string + active, retained int + param string + }{ + {"", 0, 0, "name"}, {strings.Repeat("界", 43), 1, 1, "name"}, {"private\nname", 1, 1, "name"}, + {"node", 0, 0, "max_active"}, {"node", 2, 1, "max_retained"}, {"node", 1, 1000001, "max_retained"}, + } { + err := validateRuntimeNode(tc.name, tc.active, tc.retained) + var field *AdminValidationError + if !errors.As(err, &field) || !errors.Is(err, ErrInvalidInput) || field.Param != tc.param || err.Error() != ErrInvalidInput.Error() { + t.Fatalf("wrong capacity error: %#v", err) + } + } + // Node validation retains its historical byte bound and limited controls; + // it must not silently adopt the stricter Project/key name validator. + for _, name := range []string{strings.Repeat("a", 128), "node\tname", string([]byte{0xff})} { + if err := validateRuntimeNode(name, 1, 1000000); err != nil { + t.Fatal("node acceptance changed", err) + } + } +} + +func TestSandboxValidationWrapperPreservesClassification(t *testing.T) { + _, err := SandboxSetupForSelection("00000000-0000-4000-8000-000000000001", SandboxDeploymentSetupRequest{Provider: "docker"}) + var configuration *SandboxConfigurationError + if !errors.As(err, &configuration) || !errors.Is(err, ErrInvalidInput) || configuration.Validation == nil || configuration.Validation.Param != "resources.cpus" { + t.Fatalf("missing validation metadata: %#v", err) + } + // The wrapper historically unwraps only ErrInvalidInput. Retain that contract + // while carrying the package-owned validation metadata separately. + if errors.Is(err, sandbox.ErrInvalid) { + t.Fatal("wrapper changed sentinel identity") + } + _, err = SandboxSetupForSelection("00000000-0000-4000-8000-000000000001", SandboxDeploymentSetupRequest{Provider: "e2b", DeploymentSpec: sandbox.DeploymentSpec{Runtime: &sandbox.RuntimeRelease{}}}) + if !errors.As(err, &configuration) || configuration.Validation == nil || configuration.Validation.Param != "runtime" || err.Error() != "invalid sandbox configuration: E2B Runtime is selected by its immutable template build" { + t.Fatal("pending E2B validation order changed", err) + } +} diff --git a/services/agents-api/internal/store/projects.go b/services/agents-api/internal/store/projects.go index c05bb6780..3fd3613ed 100644 --- a/services/agents-api/internal/store/projects.go +++ b/services/agents-api/internal/store/projects.go @@ -50,7 +50,7 @@ func adminResourceName(value string, max int) (string, error) { control := strings.ContainsFunc(value, unicode.IsControl) value = strings.TrimSpace(value) if value == "" || !utf8.ValidString(value) || utf8.RuneCountInString(value) > max || control { - return "", fmt.Errorf("%w: name must contain 1–%d characters without controls", ErrInvalidInput, max) + return "", &AdminValidationError{Code: "invalid_name", Param: "name", MaxLength: max, message: fmt.Sprintf("%s: name must contain 1–%d characters without controls", ErrInvalidInput, max)} } return value, nil } diff --git a/services/agents-api/internal/store/runtime_nodes.go b/services/agents-api/internal/store/runtime_nodes.go index b18835e12..409f8f163 100644 --- a/services/agents-api/internal/store/runtime_nodes.go +++ b/services/agents-api/internal/store/runtime_nodes.go @@ -35,8 +35,14 @@ func retainedLimit(provider string, active, retained int) int { return retained } func validateRuntimeNode(name string, active, retained int) error { - if strings.TrimSpace(name) == "" || len(name) > 128 || strings.ContainsAny(name, "\x00\r\n") || active < 1 || retained < active || retained > 1000000 { - return ErrInvalidInput + if strings.TrimSpace(name) == "" || len(name) > 128 || strings.ContainsAny(name, "\x00\r\n") { + return &AdminValidationError{Code: "invalid_name", Param: "name", MaxLength: 128, message: ErrInvalidInput.Error()} + } + if active < 1 { + return &AdminValidationError{Code: "invalid_node_capacity", Param: "max_active", message: ErrInvalidInput.Error()} + } + if retained < active || retained > 1000000 { + return &AdminValidationError{Code: "invalid_node_capacity", Param: "max_retained", message: ErrInvalidInput.Error()} } return nil } diff --git a/services/agents-api/internal/store/sandbox_deployment_mutations.go b/services/agents-api/internal/store/sandbox_deployment_mutations.go index 9dcd93f1f..d9a07bbe9 100644 --- a/services/agents-api/internal/store/sandbox_deployment_mutations.go +++ b/services/agents-api/internal/store/sandbox_deployment_mutations.go @@ -49,10 +49,10 @@ func E2BResourcesPending(input SandboxDeploymentSetupRequest) bool { func validateSandboxSelection(input SandboxDeploymentSetupRequest) error { if E2BResourcesPending(input) { if input.Runtime != nil { - return &SandboxConfigurationError{Message: "invalid sandbox configuration: E2B Runtime is selected by its immutable template build"} + return sandboxConfigurationError(&sandbox.ValidationError{Param: "runtime", Message: "invalid sandbox configuration: E2B Runtime is selected by its immutable template build"}) } } else if err := input.DeploymentSpec.Validate(input.Provider); err != nil { - return &SandboxConfigurationError{Message: err.Error()} + return sandboxConfigurationError(err) } switch input.Provider { case "docker", "microsandbox": @@ -106,7 +106,7 @@ func (s *Store) sandboxSelectionEqual(d sqlc.RuntimeDeployment, input SandboxDep func (s *Store) saveSandboxSelection(ctx context.Context, q *sqlc.Queries, d sqlc.RuntimeDeployment, input SandboxDeploymentSetupRequest) error { // Only a complete specification is stored, including derived E2B resources. if err := input.DeploymentSpec.Validate(input.Provider); err != nil { - return &SandboxConfigurationError{Message: err.Error()} + return sandboxConfigurationError(err) } if d.Generation == math.MaxInt64 { return ErrSandboxDeploymentConflict diff --git a/services/agents-api/internal/store/sandbox_specification.go b/services/agents-api/internal/store/sandbox_specification.go index 893657f26..cb39081a9 100644 --- a/services/agents-api/internal/store/sandbox_specification.go +++ b/services/agents-api/internal/store/sandbox_specification.go @@ -11,7 +11,16 @@ import ( ) // SandboxConfigurationError contains only validated, non-secret configuration diagnostics. -type SandboxConfigurationError struct{ Message string } +type SandboxConfigurationError struct { + Message string + Validation *sandbox.ValidationError +} + +func sandboxConfigurationError(err error) *SandboxConfigurationError { + var validation *sandbox.ValidationError + errors.As(err, &validation) + return &SandboxConfigurationError{Message: err.Error(), Validation: validation} +} func (e *SandboxConfigurationError) Error() string { return e.Message } func (e *SandboxConfigurationError) Unwrap() error { return ErrInvalidInput } From ffa2a479111b1cedde362f4621f3c5c0dd633d7b Mon Sep 17 00:00:00 2001 From: SaladDay <1203511142@qq.com> Date: Mon, 28 Sep 2026 05:19:31 +0000 Subject: [PATCH 2/2] Attribute active node capacity upper bound to max_active --- .../api/core_store_validation_test.go | 22 +++++++++++++++++++ .../internal/store/admin_validation_test.go | 5 ++++- .../internal/store/runtime_nodes.go | 2 +- 3 files changed, 27 insertions(+), 2 deletions(-) diff --git a/services/agents-api/internal/api/core_store_validation_test.go b/services/agents-api/internal/api/core_store_validation_test.go index 153302f2c..bdd1addd0 100644 --- a/services/agents-api/internal/api/core_store_validation_test.go +++ b/services/agents-api/internal/api/core_store_validation_test.go @@ -10,6 +10,7 @@ import ( "strings" "testing" + "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/device" "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/sandbox" "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" ) @@ -17,6 +18,7 @@ import ( func TestCoreStoreValidationFieldsAndPublicFallback(t *testing.T) { s := &store.Store{} _, nameErr := s.CreateProject(context.Background(), managementProjectID, strings.Repeat("private-name", 20)) + upperCapacityErr := s.UpdateRuntimeNode(context.Background(), managementProjectID, store.RuntimeNodeUpdate{Name: "node", MaxActive: 1000001, MaxRetained: 8}) capacityErr := s.UpdateRuntimeNode(context.Background(), managementProjectID, store.RuntimeNodeUpdate{Name: "node", MaxActive: 0}) _, resourceErr := store.SandboxSetupForSelection(managementProjectID, store.SandboxDeploymentSetupRequest{Provider: "docker", DeploymentSpec: sandbox.DeploymentSpec{}}) _, runtimeErr := store.SandboxSetupForSelection(managementProjectID, store.SandboxDeploymentSetupRequest{Provider: "docker", DeploymentSpec: sandbox.DeploymentSpec{Resources: sandbox.Resources{CPUs: 1, MemoryMiB: 512}}}) @@ -27,6 +29,7 @@ func TestCoreStoreValidationFieldsAndPublicFallback(t *testing.T) { publicMessage, publicCode string }{ {nameErr, "invalid_name", "name", map[string]any{"max_length": float64(128)}, "Invalid resource identifier or request limits.", "invalid_request"}, + {upperCapacityErr, "invalid_node_capacity", "max_active", map[string]any{"min": float64(1), "max": float64(1000000)}, "Invalid resource identifier or request limits.", "invalid_request"}, {capacityErr, "invalid_node_capacity", "max_active", map[string]any{"min": float64(1), "max": float64(1000000)}, "Invalid resource identifier or request limits.", "invalid_request"}, {resourceErr, "invalid_sandbox_configuration", "resources.cpus", map[string]any{"min": float64(1), "max": float64(255)}, "invalid sandbox configuration: cpus must be 1..255 and memory_mib must be 512..1048576", "invalid_sandbox_configuration"}, {runtimeErr, "invalid_sandbox_configuration", "runtime", nil, "invalid sandbox configuration: managed nodes require a pinned Runtime release", "invalid_sandbox_configuration"}, @@ -66,3 +69,22 @@ func TestCoreStoreValidationFieldsAndPublicFallback(t *testing.T) { } } } + +func TestCoreActiveCapacityUpperBoundNamesSubmittedField(t *testing.T) { + project, _ := NewAuthenticator([]APIKey{callerBinding()}) + admin, _ := NewDeploymentAuthenticator([]string{device.HashCredential("administrator")}) + h, err := NewHandler(&recordingStore{}, project, "codex", WithSandboxManager(&store.Store{}, admin)) + if err != nil { + t.Fatal(err) + } + for _, tc := range []struct{ method, path, body string }{ + {http.MethodPost, "/core/v1/sandbox/enrollment-tokens", `{"max_active":1000001}`}, + {http.MethodPatch, "/core/v1/sandbox/nodes/11111111-1111-4111-8111-111111111111", `{"name":"node","max_active":1000001,"max_retained":8}`}, + } { + out := projectKeyHTTP(h, tc.method, tc.path, "administrator", tc.body) + const golden = `{"error":{"message":"Node capacity must be positive, at most 1000000, and max_retained must be at least max_active.","type":"invalid_request_error","code":"invalid_node_capacity","param":"max_active","details":{"max":1000000,"min":1}}}` + "\n" + if out.Code != http.StatusBadRequest || out.Body.String() != golden { + t.Errorf("%s: %d %s", tc.method, out.Code, out.Body) + } + } +} diff --git a/services/agents-api/internal/store/admin_validation_test.go b/services/agents-api/internal/store/admin_validation_test.go index 789e0aa56..899b34f74 100644 --- a/services/agents-api/internal/store/admin_validation_test.go +++ b/services/agents-api/internal/store/admin_validation_test.go @@ -31,7 +31,7 @@ func TestRuntimeNodeValidationIdentityAndPrecedence(t *testing.T) { param string }{ {"", 0, 0, "name"}, {strings.Repeat("界", 43), 1, 1, "name"}, {"private\nname", 1, 1, "name"}, - {"node", 0, 0, "max_active"}, {"node", 2, 1, "max_retained"}, {"node", 1, 1000001, "max_retained"}, + {"node", 0, 0, "max_active"}, {"node", 1000001, 1000001, "max_active"}, {"node", 1000001, 8, "max_active"}, {"node", 2, 1, "max_retained"}, {"node", 1, 1000001, "max_retained"}, } { err := validateRuntimeNode(tc.name, tc.active, tc.retained) var field *AdminValidationError @@ -39,6 +39,9 @@ func TestRuntimeNodeValidationIdentityAndPrecedence(t *testing.T) { t.Fatalf("wrong capacity error: %#v", err) } } + if err := validateRuntimeNode("node", 1000000, 1000000); err != nil { + t.Fatal("inclusive active capacity bound changed", err) + } // Node validation retains its historical byte bound and limited controls; // it must not silently adopt the stricter Project/key name validator. for _, name := range []string{strings.Repeat("a", 128), "node\tname", string([]byte{0xff})} { diff --git a/services/agents-api/internal/store/runtime_nodes.go b/services/agents-api/internal/store/runtime_nodes.go index 409f8f163..99f6fdcb7 100644 --- a/services/agents-api/internal/store/runtime_nodes.go +++ b/services/agents-api/internal/store/runtime_nodes.go @@ -38,7 +38,7 @@ func validateRuntimeNode(name string, active, retained int) error { if strings.TrimSpace(name) == "" || len(name) > 128 || strings.ContainsAny(name, "\x00\r\n") { return &AdminValidationError{Code: "invalid_name", Param: "name", MaxLength: 128, message: ErrInvalidInput.Error()} } - if active < 1 { + if active < 1 || active > 1000000 { return &AdminValidationError{Code: "invalid_node_capacity", Param: "max_active", message: ErrInvalidInput.Error()} } if retained < active || retained > 1000000 {