diff --git a/services/core/IMPLEMENTATION.md b/services/core/IMPLEMENTATION.md index 0c8ea6039..5dd35520d 100644 --- a/services/core/IMPLEMENTATION.md +++ b/services/core/IMPLEMENTATION.md @@ -8,13 +8,15 @@ These are the code-level rules of `services/core` that no contract states. Contr `internal/persistence/postgres/pgunit` owns Core's PostgreSQL transaction and execution-lease mechanics: pooled read-write and snapshot transactions, the lease's dedicated connection and its gate, the ownership check, the cancellation fence, close, and the execution deadline. Persistence code runs every transaction through it, and nothing outside `persistence` and `store` imports it. `internal/persistence/postgres/pgtest` is test support: it opens the dedicated test database under the `oac_*_tests` guard, applies the migrations, and creates isolated databases for database-wide state such as the execution lease. Only test files import it. +Shared vocabulary has one owner each, and domains use it rather than copy it. `internal/environmentconfig` owns Environment setup, Skills, Plugins and initial files with their validation and public metadata; `Setup.Validate` checks requested configuration, where a Skill may be an unresolved reference, and `Setup.ValidateInstalled` checks frozen, installable configuration. `internal/skills` owns `ParseVersion`, the canonical positive decimal Skill version. `internal/metadata` owns the metadata rules: `Validate` for the pair, key and value limits and U+0000, `ValidateStorable` for U+0000 alone, and `Encode` with its 64 KiB bound. `internal/jsonobject` owns `Normalize`, the stable encoding of stored JSON objects that snapshots and retry identities compare. These packages import no persistence. + `store` is transitional. `store.New` builds a pooled Store, and `store.NewExecution` takes the lease and builds the execution writer on it. An execution-only operation on a pooled Store fails with `store.ErrExecutionAuthority`. New adapters do not copy that check: their execution repositories require a `*pgunit.Lease` at construction, their public repositories expose no execution operation, and the check goes away with `store`. ## Request handling Every Agents API JSON route reads its body through `readJSONObject` before decoding, validation or lookup. The gate requires a JSON Content-Type, applies the route's body limit and rejects invalid UTF-8, malformed JSON (including unpaired surrogate escapes), repeated keys and non-object roots with the official messages; an empty body or `null` becomes `{}`. DELETE, multipart, Core extension and internal routes keep their own readers. Member names match exactly: decode request objects with `decodeInputObject`, or check `inexactMember` before another decoder, so `encoding/json` never matches a case variant. -Report a validation failure that has official evidence through the typed field error, which emits `invalid_request_error` with the observed param and message; keep other local codes until their official fields are sampled. Saved and inline Agent configuration pass one path-tracking validator of the pinned shapes before their parsers and harness admission; do not grow it into a JSON Schema engine. A malformed path identifier must produce exactly the response of a well-formed missing one on that route, including for invalid bodies, queries and storage availability: resolve it to the never-assigned maximum UUID and let the missing path run, or reject it directly only where the lookup is the next check. An `after` cursor that does not resolve inside its already resolved parent, malformed ones included, returns that list family's observed error, and foreign and missing cursors stay identical. U+0000 is rejected explicitly only in metadata (`metadata.`); other stored strings rely on the PostgreSQL error mapping, so keep each request's writes in one transaction. +Report a validation failure that has official evidence through the typed field error, which emits `invalid_request_error` with the observed param and message; keep other local codes until their official fields are sampled. Saved and inline Agent configuration pass one path-tracking validator of the pinned shapes before their parsers and harness admission; do not grow it into a JSON Schema engine. A malformed path identifier must produce exactly the response of a well-formed missing one on that route, including for invalid bodies, queries and storage availability: resolve it to the never-assigned maximum UUID and let the missing path run, or reject it directly only where the lookup is the next check. An `after` cursor that does not resolve inside its already resolved parent, malformed ones included, returns that list family's observed error, and foreign and missing cursors stay identical. U+0000 is rejected explicitly only in metadata (`metadata.`), by the `metadata` package; other stored strings rely on the PostgreSQL error mapping, so keep each request's writes in one transaction. List queries reuse the shared parser and error serializer while keeping each family's limit bounds and error fields. The Environment Files list keeps its own path and cursor parsing but follows the same unknown-key and duplicate-key rules, and still rejects malformed query encoding that the shared lists drop. Change page bounds, cursor ownership or parent lookup order only with evidence for that family, and never reproduce an observed upstream server failure as compatibility behavior. @@ -84,7 +86,7 @@ Session status and last activity use the public projection in [`internal/api/ses ## Agents and model providers -Reusable Agents are tenant-scoped rows independent of Session snapshots and engine bindings. The store persists caller-validated configuration without applying harness restrictions or model defaults, with internal limits of 512 KiB for configuration and 64 KiB for metadata. An update locks the Agent row while merging the supplied fields and enforcing the configuration bound, then commits configuration, metadata and update time together, so a stale full snapshot never overwrites another update. An empty update preserves the saved fields and advances `updated_at` through the same SQL update. Deletion is one tenant-scoped `DELETE … RETURNING id`. A Session copies the saved configuration into its immutable snapshot and never looks up its source again. +Reusable Agents are tenant-scoped rows independent of Session snapshots and engine bindings. The store persists caller-validated configuration without applying harness restrictions or model defaults, with internal limits of 512 KiB for configuration and the `metadata.Encode` bound of 64 KiB for metadata. An update locks the Agent row while merging the supplied fields and enforcing the configuration bound, then commits configuration, metadata and update time together, so a stale full snapshot never overwrites another update. An empty update preserves the saved fields and advances `updated_at` through the same SQL update. Deletion is one tenant-scoped `DELETE … RETURNING id`. A Session copies the saved configuration into its immutable snapshot and never looks up its source again. Saved execution defaults keep a model-provider bundle whole at every replacement boundary: endpoint, key, protocol and limits are never inherited separately. Agent JSON holds only the safe provider fields and an output-only configured flag; the complete bundle is encrypted separately with a tenant and Agent binding and its own purpose, and configuration and secret changes commit together under the Agent row lock. Model-only edits need no key. Merged harness, protocol and limits are validated without reading keys. Session creation reads safe defaults and ciphertext in one snapshot, and a complete Session override does not decrypt the inherited bundle. The resolved bundle is frozen in an encrypted Session-owned row, and dispatch fails closed when that snapshot is missing or cannot be decrypted; later Agent edits, default changes, restarts and suspension never resolve it again. diff --git a/services/core/internal/api/configuration.go b/services/core/internal/api/configuration.go index 30c9a348e..386b8e5ba 100644 --- a/services/core/internal/api/configuration.go +++ b/services/core/internal/api/configuration.go @@ -5,6 +5,7 @@ import ( "errors" v1 "github.com/MiniMax-AI/OpenAgentCore/contracts/agents-api/v1" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/metadata" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/store" "github.com/google/uuid" ) @@ -20,7 +21,7 @@ func resolve(input sessionRequest, tenant, key string, saved *v1.SavedAgent) (js if input.Environment == nil || (input.Environment.Type != "none" && input.Environment.Type != "self_hosted" && input.Environment.Type != "openai_hosted") { return nil, errors.New("Unsupported environment type.") } - if err := validateMetadata(input.Metadata); err != nil { + if err := metadataFieldError(metadata.Validate(input.Metadata)); err != nil { return nil, err } agent, err := resolveSessionAgent(input, saved) diff --git a/services/core/internal/api/environment_plugins.go b/services/core/internal/api/environment_plugins.go index c596fa7f7..63cdb9d43 100644 --- a/services/core/internal/api/environment_plugins.go +++ b/services/core/internal/api/environment_plugins.go @@ -4,10 +4,11 @@ import ( "encoding/json" "github.com/MiniMax-AI/OpenAgentCore/internal/agentplugin" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/environmentconfig" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/store" ) -func decodeEnvironmentPlugins(raw json.RawMessage) ([]store.EnvironmentPlugin, error) { +func decodeEnvironmentPlugins(raw json.RawMessage) ([]environmentconfig.Plugin, error) { if len(raw) == 0 { return nil, nil } @@ -15,7 +16,7 @@ func decodeEnvironmentPlugins(raw json.RawMessage) ([]store.EnvironmentPlugin, e if json.Unmarshal(raw, &entries) != nil || len(entries) > 50 { return nil, store.ErrInvalidInput } - result := make([]store.EnvironmentPlugin, 0, len(entries)) + result := make([]environmentconfig.Plugin, 0, len(entries)) for _, entry := range entries { var input struct { Type string `json:"type"` @@ -30,9 +31,9 @@ func decodeEnvironmentPlugins(raw json.RawMessage) ([]store.EnvironmentPlugin, e if err != nil { return nil, err } - result = append(result, store.EnvironmentPlugin{Metadata: agentplugin.Metadata{Type: input.Type, Name: input.Name, Description: input.Description}, Archive: body}) + result = append(result, environmentconfig.Plugin{Metadata: agentplugin.Metadata{Type: input.Type, Name: input.Name, Description: input.Description}, Archive: body}) } - return result, store.ValidateEnvironmentPlugins(result) + return result, environmentconfig.ValidatePlugins(result) } func pluginResponse(plugins []agentplugin.Metadata) []json.RawMessage { diff --git a/services/core/internal/api/environment_setup.go b/services/core/internal/api/environment_setup.go index 131658065..ccd1b6ab6 100644 --- a/services/core/internal/api/environment_setup.go +++ b/services/core/internal/api/environment_setup.go @@ -5,6 +5,7 @@ import ( "encoding/json" v1 "github.com/MiniMax-AI/OpenAgentCore/contracts/agents-api/v1" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/environmentconfig" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/store" ) @@ -23,8 +24,8 @@ func rejectSystemPackages(raw json.RawMessage) error { return nil } -func decodeEnvironmentSetup(fields map[string]json.RawMessage) (store.EnvironmentSetup, error) { - var result store.EnvironmentSetup +func decodeEnvironmentSetup(fields map[string]json.RawMessage) (environmentconfig.Setup, error) { + var result environmentconfig.Setup if err := rejectSystemPackages(fields["packages"]); err != nil { return result, err } @@ -54,7 +55,7 @@ func decodeEnvironmentSetup(fields map[string]json.RawMessage) (store.Environmen if decodeInputObject(command, &input, "command", "cwd") != nil || input.Command == nil { return result, store.ErrInvalidInput } - step := store.SetupCommand{Command: *input.Command} + step := environmentconfig.SetupCommand{Command: *input.Command} if input.CWD != nil { if *input.CWD == "" { return result, store.ErrInvalidInput @@ -113,7 +114,7 @@ func decodeEnvironmentSetup(fields map[string]json.RawMessage) (store.Environmen } func packageMetadata(packages *v1.EnvironmentPackages) v1.EnvironmentPackagesResponse { - value := store.EnvironmentSetup{} + value := environmentconfig.Setup{} if packages != nil { value.Packages = *packages } diff --git a/services/core/internal/api/environment_skill_selectors_test.go b/services/core/internal/api/environment_skill_selectors_test.go index b21e4d1c6..d12964a41 100644 --- a/services/core/internal/api/environment_skill_selectors_test.go +++ b/services/core/internal/api/environment_skill_selectors_test.go @@ -5,6 +5,7 @@ import ( "reflect" "testing" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/environmentconfig" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/store" ) @@ -25,7 +26,7 @@ func TestSkillReferenceNullableSelectorAdmissionAndTemplateProjection(t *testing if err != nil || !template.SetSkills || len(template.Initialization.Skills) != 1 { t.Fatalf("template admission: %+v %v", template, err) } - want := store.EnvironmentSkill{Metadata: store.EnvironmentSkillMetadata{Type: "skill_reference", SkillID: "skill-owned", Version: test.selector}} + want := environmentconfig.Skill{Metadata: environmentconfig.SkillMetadata{Type: "skill_reference", SkillID: "skill-owned", Version: test.selector}} if !reflect.DeepEqual(template.Initialization.Skills[0], want) { t.Fatalf("unresolved selector changed: %+v", template.Initialization.Skills[0]) } @@ -61,8 +62,8 @@ func TestSkillReferenceNullDoesNotWidenOtherSelectors(t *testing.T) { } func TestInstalledSkillReferenceRequiresConcreteVersion(t *testing.T) { - metadata := store.EnvironmentSkillMetadata{Type: "skill_reference", SkillID: "skill-owned", Version: "2", Name: "proof", Description: "A proof."} - public := skillResponse([]store.EnvironmentSkillMetadata{metadata}) + metadata := environmentconfig.SkillMetadata{Type: "skill_reference", SkillID: "skill-owned", Version: "2", Name: "proof", Description: "A proof."} + public := skillResponse([]environmentconfig.SkillMetadata{metadata}) var reference map[string]any if len(public) != 1 || json.Unmarshal(public[0], &reference) != nil { t.Fatalf("installed projection: %s", public) diff --git a/services/core/internal/api/environment_skills.go b/services/core/internal/api/environment_skills.go index f64fc8930..102a2056e 100644 --- a/services/core/internal/api/environment_skills.go +++ b/services/core/internal/api/environment_skills.go @@ -4,10 +4,11 @@ import ( "bytes" "encoding/json" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/environmentconfig" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/store" ) -func decodeEnvironmentSkills(raw json.RawMessage) ([]store.EnvironmentSkill, error) { +func decodeEnvironmentSkills(raw json.RawMessage) ([]environmentconfig.Skill, error) { if len(raw) == 0 { return nil, nil } @@ -15,7 +16,7 @@ func decodeEnvironmentSkills(raw json.RawMessage) ([]store.EnvironmentSkill, err if json.Unmarshal(raw, &entries) != nil || len(entries) > 50 { return nil, store.ErrInvalidInput } - result := make([]store.EnvironmentSkill, 0, len(entries)) + result := make([]environmentconfig.Skill, 0, len(entries)) for _, entry := range entries { var discriminator struct { Type string `json:"type"` @@ -32,13 +33,13 @@ func decodeEnvironmentSkills(raw json.RawMessage) ([]store.EnvironmentSkill, err if decodeInputObject(entry, &reference, "type", "skill_id", "version") != nil { return nil, store.ErrInvalidInput } - metadata := store.EnvironmentSkillMetadata{Type: reference.Type, SkillID: reference.SkillID} + metadata := environmentconfig.SkillMetadata{Type: reference.Type, SkillID: reference.SkillID} if len(reference.Version) > 0 && !bytes.Equal(bytes.TrimSpace(reference.Version), []byte("null")) { if json.Unmarshal(reference.Version, &metadata.Version) != nil || metadata.Version == "" { return nil, store.ErrInvalidInput } } - result = append(result, store.EnvironmentSkill{Metadata: metadata}) + result = append(result, environmentconfig.Skill{Metadata: metadata}) continue } var input struct { @@ -54,20 +55,20 @@ func decodeEnvironmentSkills(raw json.RawMessage) ([]store.EnvironmentSkill, err if err != nil { return nil, err } - result = append(result, store.EnvironmentSkill{Metadata: store.EnvironmentSkillMetadata{Type: input.Type, Name: input.Name, Description: input.Description}, Archive: body}) + result = append(result, environmentconfig.Skill{Metadata: environmentconfig.SkillMetadata{Type: input.Type, Name: input.Name, Description: input.Description}, Archive: body}) } - return result, store.ValidateEnvironmentSkills(result) + return result, environmentconfig.ValidateSkills(result) } -func skillResponse(skills []store.EnvironmentSkillMetadata) []json.RawMessage { +func skillResponse(skills []environmentconfig.SkillMetadata) []json.RawMessage { result := make([]json.RawMessage, 0, len(skills)) for _, skill := range skills { var projection any = skill if skill.Type == "skill_reference" && skill.Version == "" { projection = struct { - store.EnvironmentSkillMetadata + environmentconfig.SkillMetadata Version *string `json:"version"` - }{EnvironmentSkillMetadata: skill} + }{SkillMetadata: skill} } raw, _ := json.Marshal(projection) result = append(result, raw) @@ -85,8 +86,8 @@ func storedSkills(raw json.RawMessage) ([]json.RawMessage, error) { } seen := map[string]bool{} for _, entry := range entries { - var metadata store.EnvironmentSkillMetadata - if decodeInputObject(entry, &metadata, "type", "name", "description", "skill_id", "version") != nil || store.ValidateInstalledSkillMetadata(metadata) != nil || seen[metadata.Name] { + var metadata environmentconfig.SkillMetadata + if decodeInputObject(entry, &metadata, "type", "name", "description", "skill_id", "version") != nil || metadata.ValidateInstalled() != nil || seen[metadata.Name] { return nil, store.ErrInvalidInput } seen[metadata.Name] = true diff --git a/services/core/internal/api/environment_skills_test.go b/services/core/internal/api/environment_skills_test.go index 6561215c7..1f03471f7 100644 --- a/services/core/internal/api/environment_skills_test.go +++ b/services/core/internal/api/environment_skills_test.go @@ -7,7 +7,7 @@ import ( "encoding/json" "testing" - "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/store" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/environmentconfig" ) func skillInput(t *testing.T, body string) json.RawMessage { @@ -32,7 +32,7 @@ func skillInput(t *testing.T, body string) json.RawMessage { } func TestSkillReferenceParsingInheritanceAndReplacement(t *testing.T) { - lookup := &templateLookupStore{network: "enabled", skills: []store.EnvironmentSkill{{Metadata: store.EnvironmentSkillMetadata{Type: "skill_reference", SkillID: "skill-template", Version: "latest"}}}} + lookup := &templateLookupStore{network: "enabled", skills: []environmentconfig.Skill{{Metadata: environmentconfig.SkillMetadata{Type: "skill_reference", SkillID: "skill-template", Version: "latest"}}}} h := templateHandler(t, lookup.ResolveEnvironmentTemplate) for _, fields := range []string{"", `,"skills":[]`, `,"skills":[{"type":"skill_reference","skill_id":"skill-override","version":"2"}]`} { var decoded decodedSessionRequest diff --git a/services/core/internal/api/environment_templates.go b/services/core/internal/api/environment_templates.go index 28198e213..b6780e232 100644 --- a/services/core/internal/api/environment_templates.go +++ b/services/core/internal/api/environment_templates.go @@ -7,6 +7,7 @@ import ( "unicode/utf8" v1 "github.com/MiniMax-AI/OpenAgentCore/contracts/agents-api/v1" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/environmentconfig" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/store" "github.com/go-chi/chi/v5" ) @@ -14,7 +15,7 @@ import ( // EnvironmentTemplates manages Environment Templates. ResolveEnvironmentTemplate // reads a Template with its initial files for Session creation. type EnvironmentTemplates interface { - ResolveEnvironmentTemplate(context.Context, string, string) (store.EnvironmentTemplate, []store.InitialFile, error) + ResolveEnvironmentTemplate(context.Context, string, string) (store.EnvironmentTemplate, []environmentconfig.InitialFile, error) CreateEnvironmentTemplate(context.Context, string, store.EnvironmentTemplateInput) (store.EnvironmentTemplate, error) GetEnvironmentTemplate(context.Context, string, string) (store.EnvironmentTemplate, error) UpdateEnvironmentTemplate(context.Context, string, string, store.EnvironmentTemplateInput) (store.EnvironmentTemplate, error) diff --git a/services/core/internal/api/environment_templates_test.go b/services/core/internal/api/environment_templates_test.go index be1475d2f..d4a790d26 100644 --- a/services/core/internal/api/environment_templates_test.go +++ b/services/core/internal/api/environment_templates_test.go @@ -8,6 +8,7 @@ import ( "strings" "testing" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/environmentconfig" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/store" ) @@ -38,18 +39,18 @@ type templateLookupStore struct { network string domains []string tenant string - skills []store.EnvironmentSkill - plugins []store.EnvironmentPlugin + skills []environmentconfig.Skill + plugins []environmentconfig.Plugin directories []string } -func (s *templateLookupStore) ResolveEnvironmentTemplate(_ context.Context, tenant, id string) (store.EnvironmentTemplate, []store.InitialFile, error) { +func (s *templateLookupStore) ResolveEnvironmentTemplate(_ context.Context, tenant, id string) (store.EnvironmentTemplate, []environmentconfig.InitialFile, error) { s.tenant = tenant - return store.EnvironmentTemplate{ID: id, NetworkAccess: s.network, AllowedDomains: s.domains, Initialization: store.EnvironmentSetup{Skills: s.skills, Plugins: s.plugins, CapabilityDirectories: s.directories}}, nil, nil + return store.EnvironmentTemplate{ID: id, NetworkAccess: s.network, AllowedDomains: s.domains, Initialization: environmentconfig.Setup{Skills: s.skills, Plugins: s.plugins, CapabilityDirectories: s.directories}}, nil, nil } // templateHandler serves Environment template lookups from resolve. -func templateHandler(t *testing.T, resolve func(context.Context, string, string) (store.EnvironmentTemplate, []store.InitialFile, error)) Handler { +func templateHandler(t *testing.T, resolve func(context.Context, string, string) (store.EnvironmentTemplate, []environmentconfig.InitialFile, error)) Handler { t.Helper() deps, fakes := testDependencies(t) fakes.environmentTemplates.resolveEnvironmentTemplate = resolve diff --git a/services/core/internal/api/errors.go b/services/core/internal/api/errors.go index d48e4ef1f..7a05e2ed0 100644 --- a/services/core/internal/api/errors.go +++ b/services/core/internal/api/errors.go @@ -8,6 +8,7 @@ import ( v1 "github.com/MiniMax-AI/OpenAgentCore/contracts/agents-api/v1" "github.com/MiniMax-AI/OpenAgentCore/internal/obs/log" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/environmentconfig" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/execution" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/providercontract" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/sandbox" @@ -235,7 +236,7 @@ func writeStoreError(w http.ResponseWriter, r *http.Request, err error, notFound writeError(w, http.StatusConflict, "turn_conflict", "The Turn cannot accept this input in its current state.") case errors.Is(err, store.ErrIdempotencyConflict): writeError(w, http.StatusConflict, "idempotency_conflict", "This idempotency key was used with different input.") - case errors.Is(err, store.ErrInvalidInput): + case errors.Is(err, store.ErrInvalidInput), errors.Is(err, environmentconfig.ErrInvalid): writeError(w, http.StatusBadRequest, "invalid_request", "Invalid resource identifier or request limits.") case store.UnstorableText(err): // A documented local limit: PostgreSQL text and jsonb cannot store U+0000, diff --git a/services/core/internal/api/fakes_test.go b/services/core/internal/api/fakes_test.go index cbc8368b1..61679767a 100644 --- a/services/core/internal/api/fakes_test.go +++ b/services/core/internal/api/fakes_test.go @@ -9,6 +9,7 @@ import ( v1 "github.com/MiniMax-AI/OpenAgentCore/contracts/agents-api/v1" "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/coremetrics" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/environmentconfig" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/identity" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/runtimehistory" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/runtimeobs" @@ -306,7 +307,7 @@ func (f *fakeDeploymentChanges) CancelSandboxReset(a0 context.Context, a1 uint64 type fakeEnvironmentTemplates struct { t testing.TB - resolveEnvironmentTemplate func(context.Context, string, string) (store.EnvironmentTemplate, []store.InitialFile, error) + resolveEnvironmentTemplate func(context.Context, string, string) (store.EnvironmentTemplate, []environmentconfig.InitialFile, error) createEnvironmentTemplate func(context.Context, string, store.EnvironmentTemplateInput) (store.EnvironmentTemplate, error) getEnvironmentTemplate func(context.Context, string, string) (store.EnvironmentTemplate, error) updateEnvironmentTemplate func(context.Context, string, string, store.EnvironmentTemplateInput) (store.EnvironmentTemplate, error) @@ -314,7 +315,7 @@ type fakeEnvironmentTemplates struct { listEnvironmentTemplates func(context.Context, string, string, int, bool) (store.EnvironmentTemplatePage, error) } -func (f *fakeEnvironmentTemplates) ResolveEnvironmentTemplate(a0 context.Context, a1 string, a2 string) (store.EnvironmentTemplate, []store.InitialFile, error) { +func (f *fakeEnvironmentTemplates) ResolveEnvironmentTemplate(a0 context.Context, a1 string, a2 string) (store.EnvironmentTemplate, []environmentconfig.InitialFile, error) { if f.resolveEnvironmentTemplate == nil { unexpectedCall(f.t, "ResolveEnvironmentTemplate") } diff --git a/services/core/internal/api/hosted_environment.go b/services/core/internal/api/hosted_environment.go index 7058e6fdb..efc075485 100644 --- a/services/core/internal/api/hosted_environment.go +++ b/services/core/internal/api/hosted_environment.go @@ -7,6 +7,7 @@ import ( v1 "github.com/MiniMax-AI/OpenAgentCore/contracts/agents-api/v1" "github.com/MiniMax-AI/OpenAgentCore/internal/agentcapabilities" "github.com/MiniMax-AI/OpenAgentCore/internal/agentnetwork" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/environmentconfig" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/store" ) @@ -123,8 +124,8 @@ func storedEnvironment(raw json.RawMessage) (*v1.Environment, error) { return nil, store.ErrInvalidInput } for _, entry := range files { - var metadata store.InitialFileMetadata - if decodeInputObject(entry, &metadata, "id", "type", "path", "file_id", "size_bytes") != nil || metadata.ID == "" || metadata.SizeBytes == nil || *metadata.SizeBytes < 0 || *metadata.SizeBytes > store.MaxInitialFileBytes { + var metadata environmentconfig.InitialFileMetadata + if decodeInputObject(entry, &metadata, "id", "type", "path", "file_id", "size_bytes") != nil || metadata.ID == "" || metadata.SizeBytes == nil || *metadata.SizeBytes < 0 || *metadata.SizeBytes > environmentconfig.MaxInitialFileBytes { return nil, store.ErrInvalidInput } if metadata.Type != "inline" && metadata.Type != "file_id" { diff --git a/services/core/internal/api/initial_files.go b/services/core/internal/api/initial_files.go index 3efab5cb7..e77fe6eb4 100644 --- a/services/core/internal/api/initial_files.go +++ b/services/core/internal/api/initial_files.go @@ -4,10 +4,11 @@ import ( "encoding/base64" "encoding/json" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/environmentconfig" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/store" ) -func decodeInitialFiles(raw json.RawMessage) ([]store.InitialFile, error) { +func decodeInitialFiles(raw json.RawMessage) ([]environmentconfig.InitialFile, error) { if len(raw) == 0 { return nil, nil } @@ -15,7 +16,7 @@ func decodeInitialFiles(raw json.RawMessage) ([]store.InitialFile, error) { if json.Unmarshal(raw, &entries) != nil || len(entries) > 50 { return nil, store.ErrInvalidInput } - files := make([]store.InitialFile, 0, len(entries)) + files := make([]environmentconfig.InitialFile, 0, len(entries)) for _, entry := range entries { var in struct { Type string `json:"type"` @@ -28,7 +29,7 @@ func decodeInitialFiles(raw json.RawMessage) ([]store.InitialFile, error) { } var fields map[string]json.RawMessage _ = json.Unmarshal(entry, &fields) - f := store.InitialFile{Type: in.Type, Path: in.Path} + f := environmentconfig.InitialFile{Type: in.Type, Path: in.Path} switch in.Type { case "inline": if _, exists := fields["file_id"]; exists || in.Data == nil || len(*in.Data) > base64.StdEncoding.EncodedLen(5<<20) { @@ -49,23 +50,14 @@ func decodeInitialFiles(raw json.RawMessage) ([]store.InitialFile, error) { } files = append(files, f) } - return files, store.ValidateInitialFiles(files) + return files, environmentconfig.ValidateInitialFiles(files) } -func initialFileResponse(files []store.InitialFile) []json.RawMessage { - metadata := make([]store.InitialFileMetadata, 0, len(files)) - for _, file := range files { - m := store.InitialFileMetadata{Type: file.Type, Path: file.Path, FileID: file.FileID} - if file.Type == "inline" { - size := int64(len(file.Data)) - m.SizeBytes = &size - } - metadata = append(metadata, m) - } - return templateFileResponse(metadata) +func initialFileResponse(files []environmentconfig.InitialFile) []json.RawMessage { + return templateFileResponse(environmentconfig.InitialFilesMetadata(files)) } -func templateFileResponse(files []store.InitialFileMetadata) []json.RawMessage { +func templateFileResponse(files []environmentconfig.InitialFileMetadata) []json.RawMessage { result := make([]json.RawMessage, 0, len(files)) for _, file := range files { body, _ := json.Marshal(file) diff --git a/services/core/internal/api/saved_configuration.go b/services/core/internal/api/saved_configuration.go index e2e726748..a9d2ec2f3 100644 --- a/services/core/internal/api/saved_configuration.go +++ b/services/core/internal/api/saved_configuration.go @@ -9,6 +9,7 @@ import ( "unicode/utf8" v1 "github.com/MiniMax-AI/OpenAgentCore/contracts/agents-api/v1" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/metadata" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/store" ) @@ -26,11 +27,11 @@ func resolveSavedFields(input v1.CreateAgentRequest) (store.CreateAgentInput, er return store.CreateAgentInput{}, &fieldError{param: "name", message: fmt.Sprintf("Invalid 'name': string too long. Expected a string with maximum length 128, but got a string with length %d instead.", length)} } } - metadata, err := stringMetadata(input.Metadata) + values, err := stringMetadata(input.Metadata) if err != nil { return store.CreateAgentInput{}, err } - if err := validateMetadata(metadata); err != nil { + if err := metadataFieldError(metadata.Validate(values)); err != nil { return store.CreateAgentInput{}, err } if err := input.XAgentsCore.Validate(); err != nil { @@ -70,7 +71,7 @@ func resolveSavedFields(input v1.CreateAgentRequest) (store.CreateAgentInput, er return store.CreateAgentInput{}, err } configuration, err := json.Marshal(cfg) - result := store.CreateAgentInput{Metadata: metadata, Configuration: configuration} + result := store.CreateAgentInput{Metadata: values, Configuration: configuration} if input.XAgentsCore != nil { result.ModelProvider = input.XAgentsCore.ModelProvider } diff --git a/services/core/internal/api/session_metadata.go b/services/core/internal/api/session_metadata.go index c658d3691..6daf53bac 100644 --- a/services/core/internal/api/session_metadata.go +++ b/services/core/internal/api/session_metadata.go @@ -3,13 +3,13 @@ package api import ( "bytes" "encoding/json" + "errors" "fmt" "maps" "net/http" "slices" - "strings" - "unicode/utf8" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/metadata" "github.com/go-chi/chi/v5" ) @@ -44,14 +44,14 @@ func (h *Handler) updateSession(w http.ResponseWriter, r *http.Request) { writeError(w, http.StatusBadRequest, "invalid_request_error", "At least one update field is required") return } - var values map[string]*string - if err := json.Unmarshal(request.Metadata, &values); err != nil { + var pairs map[string]*string + if err := json.Unmarshal(request.Metadata, &pairs); err != nil { writeError(w, http.StatusBadRequest, "invalid_request", "metadata must be null or an object with string values.") return } - metadata, err := stringMetadata(values) + values, err := stringMetadata(pairs) if err == nil { - err = validateMetadata(metadata) + err = metadataFieldError(metadata.Validate(values)) } if err != nil { if !writeFieldError(w, err) { @@ -59,7 +59,7 @@ func (h *Handler) updateSession(w http.ResponseWriter, r *http.Request) { } return } - session, err := h.Sessions.UpdateSessionMetadata(r.Context(), tenantID(r), chi.URLParam(r, "session_id"), metadata) + session, err := h.Sessions.UpdateSessionMetadata(r.Context(), tenantID(r), chi.URLParam(r, "session_id"), values) if err != nil { writeStoreError(w, r, err) return @@ -124,33 +124,26 @@ func stringMetadata(values map[string]*string) (map[string]string, error) { return metadata, nil } -// validateMetadata applies the pinned pair and character limits, then the -// local U+0000 storage limit. Sorted keys keep repeated errors stable. -func validateMetadata(metadata map[string]string) error { - if len(metadata) > 16 { - return &fieldError{param: "metadata", message: fmt.Sprintf("Invalid 'metadata': too many properties. Expected an object with at most 16 properties, but got an object with %d properties instead.", len(metadata))} - } - for _, key := range slices.Sorted(maps.Keys(metadata)) { - if length := utf8.RuneCountInString(key); length > 64 { - return &fieldError{param: "metadata." + key, message: fmt.Sprintf("Invalid property name in 'metadata': '%s' is too long. Expected a string with maximum length 64, but got a string with length %d instead.", key, length)} - } - if length := utf8.RuneCountInString(metadata[key]); length > 512 { - return &fieldError{param: "metadata." + key, message: fmt.Sprintf("Invalid 'metadata.%s': string too long. Expected a string with maximum length 512, but got a string with length %d instead.", key, length)} - } - } - return metadataCharacterError(metadata) -} - -// metadataCharacterError rejects U+0000, which PostgreSQL text and jsonb cannot -// store. The official service accepts it; this is a documented local limit. -func metadataCharacterError(metadata map[string]string) error { - for _, key := range slices.Sorted(maps.Keys(metadata)) { - if strings.ContainsRune(key, 0) { - return &fieldError{param: "metadata." + key, message: fmt.Sprintf("Invalid property name in 'metadata': '%s' contains U+0000, which this service cannot store.", key)} - } - if strings.ContainsRune(metadata[key], 0) { - return &fieldError{param: "metadata." + key, message: fmt.Sprintf("Invalid 'metadata.%s': string contains U+0000, which this service cannot store.", key)} - } - } - return nil +// metadataFieldError renders a metadata violation with the pinned messages +// and params. U+0000 is a documented local limit; the official service +// accepts it. +func metadataFieldError(err error) error { + var violation *metadata.Violation + if !errors.As(err, &violation) { + return err + } + key := violation.Key + switch violation.Kind { + case metadata.TooManyPairs: + return &fieldError{param: "metadata", message: fmt.Sprintf("Invalid 'metadata': too many properties. Expected an object with at most 16 properties, but got an object with %d properties instead.", violation.Length)} + case metadata.KeyTooLong: + return &fieldError{param: "metadata." + key, message: fmt.Sprintf("Invalid property name in 'metadata': '%s' is too long. Expected a string with maximum length 64, but got a string with length %d instead.", key, violation.Length)} + case metadata.ValueTooLong: + return &fieldError{param: "metadata." + key, message: fmt.Sprintf("Invalid 'metadata.%s': string too long. Expected a string with maximum length 512, but got a string with length %d instead.", key, violation.Length)} + case metadata.KeyUnstorable: + return &fieldError{param: "metadata." + key, message: fmt.Sprintf("Invalid property name in 'metadata': '%s' contains U+0000, which this service cannot store.", key)} + case metadata.ValueUnstorable: + return &fieldError{param: "metadata." + key, message: fmt.Sprintf("Invalid 'metadata.%s': string contains U+0000, which this service cannot store.", key)} + } + return err } diff --git a/services/core/internal/api/session_request.go b/services/core/internal/api/session_request.go index de7f14130..52213f81d 100644 --- a/services/core/internal/api/session_request.go +++ b/services/core/internal/api/session_request.go @@ -5,6 +5,7 @@ import ( "encoding/json" v1 "github.com/MiniMax-AI/OpenAgentCore/contracts/agents-api/v1" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/environmentconfig" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/store" ) @@ -23,8 +24,8 @@ type decodedSessionRequest struct { } type sessionRequest struct { - initialFiles []store.InitialFile - initialization store.EnvironmentSetup + initialFiles []environmentconfig.InitialFile + initialization environmentconfig.Setup originalEnvironment json.RawMessage modelProviderNull bool deploymentDefaults *store.DeploymentModelProviderSnapshot diff --git a/services/core/internal/api/session_template.go b/services/core/internal/api/session_template.go index db592bde9..4ef1e3231 100644 --- a/services/core/internal/api/session_template.go +++ b/services/core/internal/api/session_template.go @@ -9,6 +9,7 @@ import ( v1 "github.com/MiniMax-AI/OpenAgentCore/contracts/agents-api/v1" "github.com/MiniMax-AI/OpenAgentCore/internal/agentnetwork" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/environmentconfig" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/store" ) @@ -114,7 +115,7 @@ func (h *Handler) resolveTemplateEnvironment(ctx context.Context, tenant string, if err := setup.Validate(); err != nil { return err } - if err := store.ValidateInitialFiles(files); err != nil { + if err := environmentconfig.ValidateInitialFiles(files); err != nil { return err } input.initialization = setup diff --git a/services/core/internal/api/session_template_composition_test.go b/services/core/internal/api/session_template_composition_test.go index 62fafb3fd..dfa0e7d80 100644 --- a/services/core/internal/api/session_template_composition_test.go +++ b/services/core/internal/api/session_template_composition_test.go @@ -9,15 +9,16 @@ import ( "testing" v1 "github.com/MiniMax-AI/OpenAgentCore/contracts/agents-api/v1" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/environmentconfig" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/store" ) type compositionTemplateStore struct { template store.EnvironmentTemplate - files []store.InitialFile + files []environmentconfig.InitialFile } -func (s *compositionTemplateStore) ResolveEnvironmentTemplate(context.Context, string, string) (store.EnvironmentTemplate, []store.InitialFile, error) { +func (s *compositionTemplateStore) ResolveEnvironmentTemplate(context.Context, string, string) (store.EnvironmentTemplate, []environmentconfig.InitialFile, error) { return s.template, s.files, nil } @@ -37,39 +38,39 @@ func compositionRequest(t *testing.T, fields string) sessionRequest { func compositionFixture() *compositionTemplateStore { return &compositionTemplateStore{ - template: store.EnvironmentTemplate{NetworkAccess: "enabled", Initialization: store.EnvironmentSetup{ + template: store.EnvironmentTemplate{NetworkAccess: "enabled", Initialization: environmentconfig.Setup{ Env: map[string]string{"TEMPLATE": "private-template-env", "SHARED": "private-old-value"}, - Commands: []store.SetupCommand{{Command: "printf private-template-command"}}, + Commands: []environmentconfig.SetupCommand{{Command: "printf private-template-command"}}, Packages: v1.EnvironmentPackages{NPM: []string{"semver@7.7.2"}, Python: []string{"packaging==25.0"}}, }}, - files: []store.InitialFile{{Type: "inline", Path: "/workspace/template", Data: []byte("private-template-bytes")}}, + files: []environmentconfig.InitialFile{{Type: "inline", Path: "/workspace/template", Data: []byte("private-template-bytes")}}, } } func TestTemplateInlineCompositionRules(t *testing.T) { for _, test := range []struct { name, fields string - change func(*store.EnvironmentSetup, *[]store.InitialFile) + change func(*environmentconfig.Setup, *[]environmentconfig.InitialFile) }{ {name: "omitted"}, {name: "null", fields: `,"env":null,"setup_commands":null,"files":null,"packages":null`}, {name: "empty objects", fields: `,"env":{},"packages":{}`}, {name: "manager null", fields: `,"packages":{"npm":null,"python": null }`}, - {name: "clear lists", fields: `,"setup_commands":[],"files":[],"packages":{"npm":[],"python":[]}`, change: func(s *store.EnvironmentSetup, f *[]store.InitialFile) { + {name: "clear lists", fields: `,"setup_commands":[],"files":[],"packages":{"npm":[],"python":[]}`, change: func(s *environmentconfig.Setup, f *[]environmentconfig.InitialFile) { s.Commands, *f = nil, nil s.Packages = v1.EnvironmentPackages{} }}, - {name: "populated", fields: `,"env":{"SHARED":"private-inline-env","INLINE":"private-new-value"},"setup_commands":[{"command":"printf private-first"},{"command":"printf private-second","cwd":"/workspace"}],"files":[{"type":"inline","path":"/workspace/inline","data":"cHJpdmF0ZS1pbmxpbmUtYnl0ZXM="}],"packages":{"python":["idna==3.10"],"npm":[]}`, change: func(s *store.EnvironmentSetup, f *[]store.InitialFile) { + {name: "populated", fields: `,"env":{"SHARED":"private-inline-env","INLINE":"private-new-value"},"setup_commands":[{"command":"printf private-first"},{"command":"printf private-second","cwd":"/workspace"}],"files":[{"type":"inline","path":"/workspace/inline","data":"cHJpdmF0ZS1pbmxpbmUtYnl0ZXM="}],"packages":{"python":["idna==3.10"],"npm":[]}`, change: func(s *environmentconfig.Setup, f *[]environmentconfig.InitialFile) { s.Env["SHARED"], s.Env["INLINE"] = "private-inline-env", "private-new-value" - s.Commands = []store.SetupCommand{{Command: "printf private-first"}, {Command: "printf private-second", CWD: "/workspace"}} - *f = []store.InitialFile{{Type: "inline", Path: "/workspace/inline", Data: []byte("private-inline-bytes")}} + s.Commands = []environmentconfig.SetupCommand{{Command: "printf private-first"}, {Command: "printf private-second", CWD: "/workspace"}} + *f = []environmentconfig.InitialFile{{Type: "inline", Path: "/workspace/inline", Data: []byte("private-inline-bytes")}} s.Packages.NPM, s.Packages.Python = nil, []string{"idna==3.10"} }}, - {name: "mixed managers", fields: `,"packages":{"npm":null,"python":["idna==3.10"]}`, change: func(s *store.EnvironmentSetup, _ *[]store.InitialFile) { + {name: "mixed managers", fields: `,"packages":{"npm":null,"python":["idna==3.10"]}`, change: func(s *environmentconfig.Setup, _ *[]environmentconfig.InitialFile) { s.Packages.Python = []string{"idna==3.10"} }}, - {name: "overlapping path replaces whole list", fields: `,"files":[{"type":"inline","path":"/workspace/template","data":"bmV3"}]`, change: func(_ *store.EnvironmentSetup, f *[]store.InitialFile) { - *f = []store.InitialFile{{Type: "inline", Path: "/workspace/template", Data: []byte("new")}} + {name: "overlapping path replaces whole list", fields: `,"files":[{"type":"inline","path":"/workspace/template","data":"bmV3"}]`, change: func(_ *environmentconfig.Setup, f *[]environmentconfig.InitialFile) { + *f = []environmentconfig.InitialFile{{Type: "inline", Path: "/workspace/template", Data: []byte("new")}} }}, } { t.Run(test.name, func(t *testing.T) { @@ -186,9 +187,9 @@ func TestTemplateInlineCompositionRetainsFieldValidation(t *testing.T) { func TestTemplateFilesReplacementDoesNotCombineCounts(t *testing.T) { lookup := compositionFixture() - files := make([]store.InitialFile, 30) + files := make([]environmentconfig.InitialFile, 30) for i := range files { - files[i] = store.InitialFile{Type: "inline", Path: "/workspace/" + strings.Repeat("a", i+1)} + files[i] = environmentconfig.InitialFile{Type: "inline", Path: "/workspace/" + strings.Repeat("a", i+1)} } lookup.files = files wire := make([]map[string]string, len(files)) diff --git a/services/core/internal/api/vaults.go b/services/core/internal/api/vaults.go index b3142ba43..4bef9348a 100644 --- a/services/core/internal/api/vaults.go +++ b/services/core/internal/api/vaults.go @@ -8,6 +8,7 @@ import ( "strings" v1 "github.com/MiniMax-AI/OpenAgentCore/contracts/agents-api/v1" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/metadata" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/store" "github.com/go-chi/chi/v5" "github.com/google/uuid" @@ -75,7 +76,7 @@ func (h *Handler) createVault(w http.ResponseWriter, r *http.Request) { var err error input.Metadata, err = stringMetadata(request.Metadata) if err == nil { - err = metadataCharacterError(input.Metadata) + err = metadataFieldError(metadata.ValidateStorable(input.Metadata)) } if err != nil { if !writeFieldError(w, err) { diff --git a/services/core/internal/environmentconfig/decode.go b/services/core/internal/environmentconfig/decode.go new file mode 100644 index 000000000..f99ec8a93 --- /dev/null +++ b/services/core/internal/environmentconfig/decode.go @@ -0,0 +1,21 @@ +package environmentconfig + +import ( + "bytes" + "encoding/json" +) + +// Decode reads stored configuration JSON into out. It rejects removed +// configuration fields instead of silently dropping them, and fails with +// ErrInvalid for invalid JSON, unknown fields or a mismatched shape. +func Decode(data []byte, out any) error { + if !json.Valid(data) { + return ErrInvalid + } + decoder := json.NewDecoder(bytes.NewReader(data)) + decoder.DisallowUnknownFields() + if decoder.Decode(out) != nil { + return ErrInvalid + } + return nil +} diff --git a/services/core/internal/environmentconfig/decode_test.go b/services/core/internal/environmentconfig/decode_test.go new file mode 100644 index 000000000..77faff949 --- /dev/null +++ b/services/core/internal/environmentconfig/decode_test.go @@ -0,0 +1,33 @@ +package environmentconfig + +import ( + "errors" + "reflect" + "testing" +) + +func TestDecode(t *testing.T) { + for _, test := range []struct { + name string + data string + want []SetupCommand + }{ + {"valid", `[{"command":"true","cwd":"/workspace"}]`, []SetupCommand{{Command: "true", CWD: "/workspace"}}}, + {"invalid JSON", `[{"command":`, nil}, + {"removed field", `[{"command":"true","shell":"bash"}]`, nil}, + {"wrong shape", `{"command":"true"}`, nil}, + {"trailing value", `[] []`, nil}, + } { + var got []SetupCommand + err := Decode([]byte(test.data), &got) + if test.want == nil { + if !errors.Is(err, ErrInvalid) { + t.Errorf("%s: got %v, want ErrInvalid", test.name, err) + } + continue + } + if err != nil || !reflect.DeepEqual(got, test.want) { + t.Errorf("%s: got %+v, %v", test.name, got, err) + } + } +} diff --git a/services/core/internal/environmentconfig/doc.go b/services/core/internal/environmentconfig/doc.go new file mode 100644 index 000000000..636c35309 --- /dev/null +++ b/services/core/internal/environmentconfig/doc.go @@ -0,0 +1,6 @@ +// Package environmentconfig owns the Environment configuration vocabulary: +// setup (env, setup commands, packages and capability directories), Skills, +// Plugins and initial files, with their validation and public metadata +// projections. Sealing, reference resolution, Session freezing and storage +// belong to their owners. +package environmentconfig diff --git a/services/core/internal/environmentconfig/errors.go b/services/core/internal/environmentconfig/errors.go new file mode 100644 index 000000000..f00455dd8 --- /dev/null +++ b/services/core/internal/environmentconfig/errors.go @@ -0,0 +1,6 @@ +package environmentconfig + +import "errors" + +// ErrInvalid reports configuration that breaks a rule of this package. +var ErrInvalid = errors.New("invalid environment configuration") diff --git a/services/core/internal/environmentconfig/initial_files.go b/services/core/internal/environmentconfig/initial_files.go new file mode 100644 index 000000000..602a808a9 --- /dev/null +++ b/services/core/internal/environmentconfig/initial_files.go @@ -0,0 +1,74 @@ +package environmentconfig + +import ( + "path" + "strings" + "unicode/utf8" +) + +// MaxInitialFileBytes bounds one installed initial file, including a copied source File. +const MaxInitialFileBytes = 50 << 20 + +// InitialFile keeps confidential input separate from ordinary Session configuration. +type InitialFile struct { + Type string `json:"type"` + Path string `json:"path"` + FileID string `json:"file_id,omitempty"` + Data []byte `json:"data,omitempty"` +} + +// InitialFileMetadata is the public description of an initial file. ID is set +// once a Session freezes the file. +type InitialFileMetadata struct { + ID string `json:"id,omitempty"` + Type string `json:"type"` + Path string `json:"path"` + FileID string `json:"file_id,omitempty"` + SizeBytes *int64 `json:"size_bytes,omitempty"` +} + +func ValidateInitialFiles(files []InitialFile) error { + if len(files) > 50 { + return ErrInvalid + } + total := 0 + seen := map[string]bool{} + for _, f := range files { + if !utf8.ValidString(f.Path) || strings.ContainsAny(f.Path, "\\\x00\r\n") || len(f.Path) > 4096 || !strings.HasPrefix(f.Path, "/workspace/") || path.Clean(f.Path) != f.Path || seen[f.Path] { + return ErrInvalid + } + seen[f.Path] = true + switch f.Type { + case "inline": + if f.FileID != "" || len(f.Data) > 5<<20 { + return ErrInvalid + } + total += len(f.Data) + case "file_id": + if f.FileID == "" || len(f.Data) != 0 { + return ErrInvalid + } + default: + return ErrInvalid + } + } + if total > 10<<20 { + return ErrInvalid + } + return nil +} + +// InitialFilesMetadata describes requested files in order; only inline files +// know their size before a Session freezes them. +func InitialFilesMetadata(files []InitialFile) []InitialFileMetadata { + result := make([]InitialFileMetadata, 0, len(files)) + for _, f := range files { + m := InitialFileMetadata{Type: f.Type, Path: f.Path, FileID: f.FileID} + if f.Type == "inline" { + size := int64(len(f.Data)) + m.SizeBytes = &size + } + result = append(result, m) + } + return result +} diff --git a/services/core/internal/environmentconfig/initial_files_test.go b/services/core/internal/environmentconfig/initial_files_test.go new file mode 100644 index 000000000..a754b60a1 --- /dev/null +++ b/services/core/internal/environmentconfig/initial_files_test.go @@ -0,0 +1,41 @@ +package environmentconfig + +import ( + "errors" + "reflect" + "testing" +) + +func TestValidateInitialFiles(t *testing.T) { + for name, files := range map[string][]InitialFile{ + "outside workspace": {{Type: "inline", Path: "/tmp/a"}}, + "unclean path": {{Type: "inline", Path: "/workspace/a/../b"}}, + "backslash": {{Type: "inline", Path: "/workspace/a\\b"}}, + "duplicate path": {{Type: "inline", Path: "/workspace/a"}, {Type: "file_id", Path: "/workspace/a", FileID: "file_1"}}, + "inline with file ID": {{Type: "inline", Path: "/workspace/a", FileID: "file_1"}}, + "file ID without ID": {{Type: "file_id", Path: "/workspace/a"}}, + "file ID with data": {{Type: "file_id", Path: "/workspace/a", FileID: "file_1", Data: []byte("x")}}, + "unknown type": {{Type: "url", Path: "/workspace/a"}}, + "inline over 5 MiB": {{Type: "inline", Path: "/workspace/a", Data: make([]byte, 5<<20+1)}}, + "inline total 10 MiB": {{Type: "inline", Path: "/workspace/a", Data: make([]byte, 5<<20)}, {Type: "inline", Path: "/workspace/b", Data: make([]byte, 5<<20)}, {Type: "inline", Path: "/workspace/c", Data: []byte("x")}}, + } { + if err := ValidateInitialFiles(files); !errors.Is(err, ErrInvalid) { + t.Errorf("%s: got %v, want ErrInvalid", name, err) + } + } + if err := ValidateInitialFiles([]InitialFile{{Type: "inline", Path: "/workspace/a", Data: []byte("x")}, {Type: "file_id", Path: "/workspace/b", FileID: "file_1"}}); err != nil { + t.Fatalf("valid files rejected: %v", err) + } +} + +func TestInitialFilesMetadata(t *testing.T) { + size := int64(3) + got := InitialFilesMetadata([]InitialFile{{Type: "inline", Path: "/workspace/a", Data: []byte("abc")}, {Type: "file_id", Path: "/workspace/b", FileID: "file_1"}}) + want := []InitialFileMetadata{{Type: "inline", Path: "/workspace/a", SizeBytes: &size}, {Type: "file_id", Path: "/workspace/b", FileID: "file_1"}} + if !reflect.DeepEqual(got, want) { + t.Fatalf("metadata = %+v, want %+v", got, want) + } + if got := InitialFilesMetadata(nil); got == nil || len(got) != 0 { + t.Fatalf("no files = %#v, want an empty list", got) + } +} diff --git a/services/core/internal/environmentconfig/plugins.go b/services/core/internal/environmentconfig/plugins.go new file mode 100644 index 000000000..df6e823e2 --- /dev/null +++ b/services/core/internal/environmentconfig/plugins.go @@ -0,0 +1,46 @@ +package environmentconfig + +import "github.com/MiniMax-AI/OpenAgentCore/internal/agentplugin" + +const maxPluginsArchiveBytes = 10 << 20 + +// Plugin separates safe identity from confidential immutable input. +type Plugin struct { + Metadata agentplugin.Metadata `json:"metadata"` + Archive []byte `json:"archive"` +} + +func ValidatePlugins(plugins []Plugin) error { + if len(plugins) > 50 { + return ErrInvalid + } + seen := map[string]bool{} + compressed, expanded := 0, 0 + for _, plugin := range plugins { + compressed += len(plugin.Archive) + if compressed > maxPluginsArchiveBytes || seen[plugin.Metadata.Name] { + return ErrInvalid + } + seen[plugin.Metadata.Name] = true + bundle, err := agentplugin.Read(plugin.Archive, plugin.Metadata) + if err != nil { + return ErrInvalid + } + for _, file := range bundle.Files { + expanded += len(file.Data) + } + if expanded > 50<<20 { + return ErrInvalid + } + } + return nil +} + +// PluginMetadata returns the public metadata of every Plugin in order. +func (s Setup) PluginMetadata() []agentplugin.Metadata { + result := make([]agentplugin.Metadata, 0, len(s.Plugins)) + for _, plugin := range s.Plugins { + result = append(result, plugin.Metadata) + } + return result +} diff --git a/services/core/internal/environmentconfig/setup.go b/services/core/internal/environmentconfig/setup.go new file mode 100644 index 000000000..7e18e86db --- /dev/null +++ b/services/core/internal/environmentconfig/setup.go @@ -0,0 +1,90 @@ +package environmentconfig + +import ( + "encoding/json" + "path" + "regexp" + "strings" + + v1 "github.com/MiniMax-AI/OpenAgentCore/contracts/agents-api/v1" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentcapabilities" +) + +// Setup is confidential input, never ordinary resource metadata. +// Core freezes it once; the common Runtime initializer executes it in order. +type Setup struct { + Env map[string]string `json:"env,omitempty"` + Commands []SetupCommand `json:"setup_commands,omitempty"` + Packages v1.EnvironmentPackages `json:"packages"` + Skills []Skill `json:"skills,omitempty"` + Plugins []Plugin `json:"plugins,omitempty"` + CapabilityDirectories []string `json:"capability_directories,omitempty"` +} + +type SetupCommand struct { + Command string `json:"command"` + CWD string `json:"cwd,omitempty"` +} + +var environmentName = regexp.MustCompile(`^[A-Za-z_][A-Za-z0-9_]*$`) + +func (s Setup) Empty() bool { + return len(s.Env)+len(s.Commands)+len(s.Packages.NPM)+len(s.Packages.Python)+len(s.Skills)+len(s.Plugins)+len(s.CapabilityDirectories) == 0 +} + +// Validate checks requested configuration, where a Skill may still be an +// unresolved reference. +func (s Setup) Validate() error { + return s.validate(false) +} + +// ValidateInstalled checks installable configuration: every Skill carries its +// concrete metadata and frozen archive. +func (s Setup) ValidateInstalled() error { + return s.validate(true) +} + +func (s Setup) validate(installed bool) error { + if validateSkills(s.Skills, installed) != nil || ValidatePlugins(s.Plugins) != nil || agentcapabilities.ValidateSourceDirectories(s.CapabilityDirectories) != nil { + return ErrInvalid + } + ordinary := s + ordinary.Skills = nil + ordinary.Plugins = nil + raw, err := json.Marshal(ordinary) + if err != nil || len(raw) > 512*1024 { + return ErrInvalid + } + for name, value := range s.Env { + // The first three reservations are explicitly part of the public guide; + // OAC_* identifies the actual deployment authority and binding. + if !environmentName.MatchString(name) || name == "PATH" || name == "OPENAI_API_KEY" || strings.HasPrefix(name, "CODEX_") || strings.HasPrefix(name, "OAC_") || strings.ContainsRune(value, 0) { + return ErrInvalid + } + } + for _, command := range s.Commands { + if command.Command == "" || strings.ContainsRune(command.Command, 0) || (command.CWD != "" && (!path.IsAbs(command.CWD) || strings.ContainsRune(command.CWD, 0))) { + return ErrInvalid + } + } + for _, packages := range [][]string{s.Packages.NPM, s.Packages.Python} { + for _, item := range packages { + if item == "" || strings.HasPrefix(item, "-") || strings.ContainsRune(item, 0) { + return ErrInvalid + } + } + } + return nil +} + +// PackageMetadata returns the public package lists, empty rather than null. +func (s Setup) PackageMetadata() v1.EnvironmentPackages { + result := s.Packages + if result.NPM == nil { + result.NPM = []string{} + } + if result.Python == nil { + result.Python = []string{} + } + return result +} diff --git a/services/core/internal/environmentconfig/setup_test.go b/services/core/internal/environmentconfig/setup_test.go new file mode 100644 index 000000000..6b18c71a4 --- /dev/null +++ b/services/core/internal/environmentconfig/setup_test.go @@ -0,0 +1,113 @@ +package environmentconfig + +import ( + "archive/zip" + "bytes" + "errors" + "testing" + + v1 "github.com/MiniMax-AI/OpenAgentCore/contracts/agents-api/v1" +) + +func TestSetupReservesOpenAgentCoreNames(t *testing.T) { + for _, name := range []string{"OAC_ADDR", "OAC_RUNTIME_HOME", "OAC_WEB_ORIGIN", "OAC_LOG_LEVEL", "OAC_DEV_HOME", "OAC_TEST_DATABASE_URL"} { + if err := (Setup{Env: map[string]string{name: "value"}}).Validate(); !errors.Is(err, ErrInvalid) { + t.Fatalf("reserved name %s accepted: %v", name, err) + } + } + if err := (Setup{Env: map[string]string{"APPLICATION_VALUE": "ok"}}).Validate(); err != nil { + t.Fatalf("ordinary application settings rejected: %v", err) + } +} + +func TestSetupValidate(t *testing.T) { + for name, setup := range map[string]Setup{ + "invalid env name": {Env: map[string]string{"1NAME": "value"}}, + "reserved PATH": {Env: map[string]string{"PATH": "/bin"}}, + "reserved CODEX prefix": {Env: map[string]string{"CODEX_HOME": "/home"}}, + "NUL in env value": {Env: map[string]string{"NAME": "a\x00b"}}, + "empty command": {Commands: []SetupCommand{{Command: ""}}}, + "relative command cwd": {Commands: []SetupCommand{{Command: "true", CWD: "workspace"}}}, + "option as package": {Packages: v1.EnvironmentPackages{NPM: []string{"--global"}}}, + "empty package": {Packages: v1.EnvironmentPackages{Python: []string{""}}}, + } { + if err := setup.Validate(); !errors.Is(err, ErrInvalid) { + t.Errorf("%s: got %v, want ErrInvalid", name, err) + } + } + valid := Setup{Env: map[string]string{"NAME": "value"}, Commands: []SetupCommand{{Command: "true", CWD: "/workspace"}}, Packages: v1.EnvironmentPackages{NPM: []string{"is-number@7.0.0"}, Python: []string{"packaging==24.2"}}} + if err := valid.Validate(); err != nil { + t.Fatalf("valid setup rejected: %v", err) + } +} + +func TestSetupValidateRequestedAndInstalledSkills(t *testing.T) { + reference := func(version string) Setup { + return Setup{Skills: []Skill{{Metadata: SkillMetadata{Type: "skill_reference", SkillID: "skill_1", Version: version}}}} + } + for _, version := range []string{"", "latest", "3"} { + if err := reference(version).Validate(); err != nil { + t.Errorf("requested reference version %q rejected: %v", version, err) + } + if err := reference(version).ValidateInstalled(); !errors.Is(err, ErrInvalid) { + t.Errorf("unresolved reference version %q installed: %v", version, err) + } + } + if err := reference("03").Validate(); !errors.Is(err, ErrInvalid) { + t.Errorf("non-canonical requested version accepted: %v", err) + } + resolved := Setup{Skills: []Skill{{Metadata: SkillMetadata{Type: "skill_reference", Name: "proof", Description: "A proof.", SkillID: "skill_1", Version: "3"}, Archive: skillArchive(t)}}} + if err := resolved.ValidateInstalled(); err != nil { + t.Errorf("resolved reference rejected: %v", err) + } + if err := resolved.Validate(); !errors.Is(err, ErrInvalid) { + t.Errorf("requested reference with resolved fields accepted: %v", err) + } + inline := Setup{Skills: []Skill{{Metadata: SkillMetadata{Type: "inline", Name: "proof", Description: "A proof."}, Archive: skillArchive(t)}}} + if inline.Validate() != nil || inline.ValidateInstalled() != nil { + t.Error("inline Skill rejected") + } + duplicate := Setup{Skills: append(inline.Skills, inline.Skills...)} + if err := duplicate.Validate(); !errors.Is(err, ErrInvalid) { + t.Errorf("duplicate Skill name accepted: %v", err) + } +} + +func TestSkillMetadataValidateInstalled(t *testing.T) { + for _, test := range []struct { + name string + metadata SkillMetadata + valid bool + }{ + {"inline", SkillMetadata{Type: "inline", Name: "proof", Description: "A proof."}, true}, + {"reference", SkillMetadata{Type: "skill_reference", Name: "proof", Description: "A proof.", SkillID: "skill_1", Version: "1"}, true}, + {"missing name", SkillMetadata{Type: "inline", Description: "A proof."}, false}, + {"missing description", SkillMetadata{Type: "inline", Name: "proof"}, false}, + {"inline with version", SkillMetadata{Type: "inline", Name: "proof", Description: "A proof.", Version: "1"}, false}, + {"reference without ID", SkillMetadata{Type: "skill_reference", Name: "proof", Description: "A proof.", Version: "1"}, false}, + {"reference with latest", SkillMetadata{Type: "skill_reference", Name: "proof", Description: "A proof.", SkillID: "skill_1", Version: "latest"}, false}, + {"unknown type", SkillMetadata{Type: "remote", Name: "proof", Description: "A proof."}, false}, + } { + err := test.metadata.ValidateInstalled() + if test.valid && err != nil || !test.valid && !errors.Is(err, ErrInvalid) { + t.Errorf("%s: got %v", test.name, err) + } + } +} + +func skillArchive(t *testing.T) []byte { + t.Helper() + var archive bytes.Buffer + writer := zip.NewWriter(&archive) + file, err := writer.CreateHeader(&zip.FileHeader{Name: "proof/SKILL.md", Method: zip.Store}) + if err != nil { + t.Fatal(err) + } + if _, err := file.Write([]byte("---\nname: proof\ndescription: A proof.\n---\nBody.")); err != nil { + t.Fatal(err) + } + if err := writer.Close(); err != nil { + t.Fatal(err) + } + return archive.Bytes() +} diff --git a/services/core/internal/environmentconfig/skills.go b/services/core/internal/environmentconfig/skills.go new file mode 100644 index 000000000..4b26dbcc5 --- /dev/null +++ b/services/core/internal/environmentconfig/skills.go @@ -0,0 +1,111 @@ +package environmentconfig + +import ( + "github.com/MiniMax-AI/OpenAgentCore/internal/agentskill" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/skills" +) + +// SkillMetadata is either template intent or concrete installed metadata. +// References acquire their name, description and concrete version at Session commit. +type SkillMetadata struct { + Type string `json:"type"` + Name string `json:"name,omitempty"` + Description string `json:"description,omitempty"` + SkillID string `json:"skill_id,omitempty"` + Version string `json:"version,omitempty"` +} + +// Skill keeps confidential content separate from public metadata. +// A template reference has no archive; a committed Session always has frozen bytes. +type Skill struct { + Metadata SkillMetadata `json:"metadata"` + Archive []byte `json:"archive,omitempty"` +} + +// InstallationMetadata removes public reference identity at the installer boundary. +func (s Skill) InstallationMetadata() agentskill.Metadata { + return agentskill.Metadata{Type: "inline", Name: s.Metadata.Name, Description: s.Metadata.Description} +} + +const maxSkillsArchiveBytes = 10 << 20 + +// ValidateSkills checks requested Skills, where a reference names a Skill and +// an optional version or "latest". +func ValidateSkills(skills []Skill) error { + return validateSkills(skills, false) +} + +// ValidateInstalled checks the metadata of an installed Skill: an inline Skill +// or a reference resolved to a concrete version. +func (m SkillMetadata) ValidateInstalled() error { + if m.Name == "" || m.Description == "" { + return ErrInvalid + } + switch m.Type { + case "inline": + if m.SkillID != "" || m.Version != "" { + return ErrInvalid + } + case "skill_reference": + if m.SkillID == "" { + return ErrInvalid + } + if _, err := skills.ParseVersion(m.Version); err != nil { + return ErrInvalid + } + default: + return ErrInvalid + } + return nil +} + +func validateSkills(requested []Skill, installed bool) error { + if len(requested) > 50 { + return ErrInvalid + } + seen := map[string]bool{} + total := 0 + expanded := 0 + for _, skill := range requested { + if !installed && skill.Metadata.Type == "skill_reference" { + m := skill.Metadata + if m.SkillID == "" || len(m.SkillID) > 256 || m.Name != "" || m.Description != "" || len(skill.Archive) != 0 { + return ErrInvalid + } + if m.Version != "" && m.Version != "latest" { + if _, err := skills.ParseVersion(m.Version); err != nil { + return ErrInvalid + } + } + continue + } + if err := skill.Metadata.ValidateInstalled(); err != nil { + return err + } + total += len(skill.Archive) + if total > maxSkillsArchiveBytes || seen[skill.Metadata.Name] { + return ErrInvalid + } + seen[skill.Metadata.Name] = true + files, err := agentskill.Read(skill.Archive, skill.InstallationMetadata()) + if err != nil { + return ErrInvalid + } + for _, file := range files { + expanded += len(file.Data) + } + if expanded > 50<<20 { + return ErrInvalid + } + } + return nil +} + +// SkillMetadata returns the public metadata of every Skill in order. +func (s Setup) SkillMetadata() []SkillMetadata { + result := make([]SkillMetadata, 0, len(s.Skills)) + for _, skill := range s.Skills { + result = append(result, skill.Metadata) + } + return result +} diff --git a/services/core/internal/execution/environment_placement.go b/services/core/internal/execution/environment_placement.go index 2f49ef458..9b2f26725 100644 --- a/services/core/internal/execution/environment_placement.go +++ b/services/core/internal/execution/environment_placement.go @@ -9,18 +9,19 @@ import ( "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" "github.com/MiniMax-AI/OpenAgentCore/internal/agentnetwork" "github.com/MiniMax-AI/OpenAgentCore/internal/agentplugin" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/environmentconfig" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/store" ) type environmentPlacement struct { - Plugins []agentplugin.Metadata `json:"plugins,omitempty"` - Skills []store.EnvironmentSkillMetadata `json:"skills,omitempty"` - Type string `json:"type"` - ToolEnvironment bool `json:"initialization,omitempty"` - NetworkAccess string `json:"-"` - AllowedDomains []string `json:"-"` - WorkspaceDirectory string `json:"workspace_directory"` - CapabilityDirectories []string `json:"capability_directories"` + Plugins []agentplugin.Metadata `json:"plugins,omitempty"` + Skills []environmentconfig.SkillMetadata `json:"skills,omitempty"` + Type string `json:"type"` + ToolEnvironment bool `json:"initialization,omitempty"` + NetworkAccess string `json:"-"` + AllowedDomains []string `json:"-"` + WorkspaceDirectory string `json:"workspace_directory"` + CapabilityDirectories []string `json:"capability_directories"` } // LocalWorkspaceConfiguration recognizes the qualified stored V1 profile. It @@ -36,15 +37,15 @@ func parseEnvironmentPlacement(configuration json.RawMessage) (environmentPlacem return placement, store.ErrInvalidInput } var local struct { - Plugins []agentplugin.Metadata `json:"plugins,omitempty"` - Skills []store.EnvironmentSkillMetadata `json:"skills,omitempty"` - Files []store.InitialFileMetadata `json:"files"` - Packages *v1.EnvironmentPackages `json:"packages,omitempty"` - Initialization bool `json:"initialization,omitempty"` - Type string `json:"type"` - WorkspaceDirectory string `json:"workspace_directory,omitempty"` - CapabilityDirectories []string `json:"capability_directories"` - Network *v1.EnvironmentNetworkInput `json:"network"` + Plugins []agentplugin.Metadata `json:"plugins,omitempty"` + Skills []environmentconfig.SkillMetadata `json:"skills,omitempty"` + Files []environmentconfig.InitialFileMetadata `json:"files"` + Packages *v1.EnvironmentPackages `json:"packages,omitempty"` + Initialization bool `json:"initialization,omitempty"` + Type string `json:"type"` + WorkspaceDirectory string `json:"workspace_directory,omitempty"` + CapabilityDirectories []string `json:"capability_directories"` + Network *v1.EnvironmentNetworkInput `json:"network"` } decoder := json.NewDecoder(bytes.NewReader(configuration)) decoder.DisallowUnknownFields() @@ -89,10 +90,10 @@ func (d *Dispatcher) configurePreparedEnvironment(session store.Session, environ } sources := &agentcapabilities.Input{Plugins: append([]agentplugin.Metadata(nil), placement.Plugins...), Directories: append([]string(nil), placement.CapabilityDirectories...)} for _, metadata := range placement.Skills { - if store.ValidateInstalledSkillMetadata(metadata) != nil { + if metadata.ValidateInstalled() != nil { return store.ErrInvalidInput } - sources.Skills = append(sources.Skills, (store.EnvironmentSkill{Metadata: metadata}).InstallationMetadata()) + sources.Skills = append(sources.Skills, (environmentconfig.Skill{Metadata: metadata}).InstallationMetadata()) } req.LocalEnvironment = &proto.LocalEnvironment{ ID: environment.ID, WorkspaceDirectory: placement.WorkspaceDirectory, diff --git a/services/core/internal/execution/runtime_capabilities_test.go b/services/core/internal/execution/runtime_capabilities_test.go index 5ba2f2c35..d7231090f 100644 --- a/services/core/internal/execution/runtime_capabilities_test.go +++ b/services/core/internal/execution/runtime_capabilities_test.go @@ -7,7 +7,7 @@ import ( "github.com/MiniMax-AI/OpenAgentCore/internal/agentcapabilities" "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" "github.com/MiniMax-AI/OpenAgentCore/internal/agentplugin" - "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/store" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/environmentconfig" "github.com/google/uuid" "testing" ) @@ -26,10 +26,10 @@ func (f *capabilityFixture) PrepareRuntime(_ context.Context, id string, request } func TestRuntimeCapabilitiesPreserveRawBundlesAndSetupOrdering(t *testing.T) { archive := []byte("opaque archive bytes must be expanded only by Runtime") - setup := store.EnvironmentSetup{ - Skills: []store.EnvironmentSkill{{Metadata: store.EnvironmentSkillMetadata{Type: "skill_reference", SkillID: "private-reference", Version: "1", Name: "example", Description: "Safe description"}, Archive: archive}}, - Plugins: []store.EnvironmentPlugin{{Metadata: agentplugin.Metadata{Type: "inline", Name: "plugin", Description: "Safe plugin"}, Archive: archive}}, - Commands: []store.SetupCommand{{Command: "prepare directory"}}, + setup := environmentconfig.Setup{ + Skills: []environmentconfig.Skill{{Metadata: environmentconfig.SkillMetadata{Type: "skill_reference", SkillID: "private-reference", Version: "1", Name: "example", Description: "Safe description"}, Archive: archive}}, + Plugins: []environmentconfig.Plugin{{Metadata: agentplugin.Metadata{Type: "inline", Name: "plugin", Description: "Safe plugin"}, Archive: archive}}, + Commands: []environmentconfig.SetupCommand{{Command: "prepare directory"}}, CapabilityDirectories: []string{"/workspace/generated"}, } operations := setupOperations(setup) diff --git a/services/core/internal/execution/runtime_initialization.go b/services/core/internal/execution/runtime_initialization.go index 7bc7bf7c5..8db47fce1 100644 --- a/services/core/internal/execution/runtime_initialization.go +++ b/services/core/internal/execution/runtime_initialization.go @@ -9,6 +9,7 @@ import ( "github.com/MiniMax-AI/OpenAgentCore/internal/agentcapabilities" "github.com/MiniMax-AI/OpenAgentCore/internal/obs/log" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/environmentconfig" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/runtimegateway" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/store" ) @@ -112,7 +113,7 @@ func (w *Worker) prepareEnvironment(ctx context.Context, owner store.Environment return err } var cfg struct { - Files []store.InitialFileMetadata `json:"files"` + Files []environmentconfig.InitialFileMetadata `json:"files"` } if json.Unmarshal(environment.Configuration, &cfg) != nil || len(cfg.Files) > 50 { return store.ErrInvalidInput @@ -139,7 +140,7 @@ func (w *Worker) prepareEnvironment(ctx context.Context, owner store.Environment } candidate := store.ProvisioningFailure{Step: store.ProvisioningInitialFile} if err == nil && index < len(cfg.Files) { - var metadata store.InitialFileMetadata + var metadata environmentconfig.InitialFileMetadata var body []byte metadata, body, err = w.dispatcher.Store.ReadInitialEnvironmentFile(step, owner.TenantID, owner.SessionID, index) if err == nil { diff --git a/services/core/internal/execution/runtime_setup.go b/services/core/internal/execution/runtime_setup.go index 3b63c6a6b..945ee8589 100644 --- a/services/core/internal/execution/runtime_setup.go +++ b/services/core/internal/execution/runtime_setup.go @@ -6,6 +6,7 @@ import ( "github.com/MiniMax-AI/OpenAgentCore/internal/agentcapabilities" "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/environmentconfig" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/store" "github.com/google/uuid" ) @@ -34,7 +35,7 @@ func (operation runtimeSetupOperation) provisioningFailure(exitCode int) store.P return store.ProvisioningFailure{} } -func setupOperations(setup store.EnvironmentSetup) []runtimeSetupOperation { +func setupOperations(setup environmentconfig.Setup) []runtimeSetupOperation { if setup.Empty() { return nil } @@ -97,8 +98,8 @@ func runRuntimeSetup(ctx context.Context, peer runtimePreparer, identity agentca return errors.New("environment initialization operation unconfirmed") } -func installInitialFile(ctx context.Context, peer runtimePreparer, identity agentcapabilities.Identity, file store.InitialFileMetadata, body []byte) error { - if file.SizeBytes == nil || *file.SizeBytes != int64(len(body)) || len(body) > store.MaxInitialFileBytes { +func installInitialFile(ctx context.Context, peer runtimePreparer, identity agentcapabilities.Identity, file environmentconfig.InitialFileMetadata, body []byte) error { + if file.SizeBytes == nil || *file.SizeBytes != int64(len(body)) || len(body) > environmentconfig.MaxInitialFileBytes { return errors.New("environment initialization request unavailable") } return runRuntimeSetup(ctx, peer, identity, runtimeSetupOperation{Request: proto.RuntimePreparePayload{Action: "file", File: &proto.RuntimeInitialFile{Path: file.Path}}, Data: body}) diff --git a/services/core/internal/execution/runtime_setup_test.go b/services/core/internal/execution/runtime_setup_test.go index 38c75c7de..877462464 100644 --- a/services/core/internal/execution/runtime_setup_test.go +++ b/services/core/internal/execution/runtime_setup_test.go @@ -8,6 +8,7 @@ import ( "github.com/MiniMax-AI/OpenAgentCore/internal/agentcapabilities" "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/environmentconfig" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/store" ) @@ -62,7 +63,7 @@ func TestRuntimeSetupFailureLabels(t *testing.T) { t.Fatal(action, got) } } - operations := setupOperations(store.EnvironmentSetup{Commands: []store.SetupCommand{{Command: "a"}, {Command: "b"}}}) + operations := setupOperations(environmentconfig.Setup{Commands: []environmentconfig.SetupCommand{{Command: "a"}, {Command: "b"}}}) if len(operations) != 3 || operations[1].Index != 0 || operations[2].Index != 1 || operations[2].Request.Initialization.CWD != "" { t.Fatal("command index or Runtime default changed") } @@ -72,14 +73,14 @@ func TestInitialFileUsesTypedRuntimeBytes(t *testing.T) { size := int64(len(body)) owner := agentcapabilities.Identity{EnvironmentID: "environment", SessionID: "session"} peer := &receiptRuntime{result: proto.RuntimePrepareResultPayload{Outcome: "completed"}} - if err := installInitialFile(t.Context(), peer, owner, store.InitialFileMetadata{Path: "/workspace/a", SizeBytes: &size}, body); err != nil { + if err := installInitialFile(t.Context(), peer, owner, environmentconfig.InitialFileMetadata{Path: "/workspace/a", SizeBytes: &size}, body); err != nil { t.Fatal(err) } if peer.request.Action != "file" || peer.request.File.Path != "/workspace/a" || peer.request.EnvironmentID != owner.EnvironmentID || peer.request.SessionID != owner.SessionID || string(peer.data) != setupCanary { t.Fatal("file transport changed") } size++ - if err := installInitialFile(t.Context(), peer, owner, store.InitialFileMetadata{SizeBytes: &size}, body); err == nil { + if err := installInitialFile(t.Context(), peer, owner, environmentconfig.InitialFileMetadata{SizeBytes: &size}, body); err == nil { t.Fatal("mismatched source size accepted") } } diff --git a/services/core/internal/jsonobject/jsonobject.go b/services/core/internal/jsonobject/jsonobject.go new file mode 100644 index 000000000..61d937564 --- /dev/null +++ b/services/core/internal/jsonobject/jsonobject.go @@ -0,0 +1,42 @@ +// Package jsonobject normalizes stored JSON object documents. +package jsonobject + +import ( + "bytes" + "encoding/json" + "errors" + "io" +) + +// ErrInvalid reports a value that is not exactly one JSON object. +var ErrInvalid = errors.New("value must be exactly one JSON object") + +// Normalize returns one stable encoding of a JSON object: an empty value +// becomes {}, members are sorted by name, insignificant whitespace is removed +// and numbers keep their literal text. Anything other than a single object, +// including null and trailing values, fails with ErrInvalid. +// +// Objects with the same members and number literals therefore encode +// identically, which stored snapshots and retry identities rely on. It is not +// a general canonical JSON form: strings keep encoding/json's escaping, +// numbers are not rewritten (1 and 1.0 differ) and a repeated member keeps +// its last value. +func Normalize(raw json.RawMessage) (json.RawMessage, error) { + if len(raw) == 0 { + return json.RawMessage(`{}`), nil + } + decoder := json.NewDecoder(bytes.NewReader(raw)) + decoder.UseNumber() + var fields map[string]any + if err := decoder.Decode(&fields); err != nil || fields == nil { + return nil, ErrInvalid + } + if err := decoder.Decode(new(any)); err != io.EOF { + return nil, ErrInvalid + } + normalized, err := json.Marshal(fields) + if err != nil { + return nil, ErrInvalid + } + return normalized, nil +} diff --git a/services/core/internal/jsonobject/jsonobject_test.go b/services/core/internal/jsonobject/jsonobject_test.go new file mode 100644 index 000000000..4a9db9da0 --- /dev/null +++ b/services/core/internal/jsonobject/jsonobject_test.go @@ -0,0 +1,25 @@ +package jsonobject + +import ( + "errors" + "testing" +) + +func TestNormalize(t *testing.T) { + for _, test := range []struct{ input, want string }{ + {``, `{}`}, + {` { } `, `{}`}, + {` {"environment":{"type":"none"},"agent":{"revision":9007199254740993,"model":"example"}} `, `{"agent":{"model":"example","revision":9007199254740993},"environment":{"type":"none"}}`}, + {`{"value":1.50e3}`, `{"value":1.50e3}`}, + } { + got, err := Normalize([]byte(test.input)) + if err != nil || string(got) != test.want { + t.Errorf("Normalize(%q) = %s, %v; want %s", test.input, got, err, test.want) + } + } + for _, raw := range []string{`null`, `[]`, `"text"`, `1`, `{} {}`, `{} null`, `{`} { + if _, err := Normalize([]byte(raw)); !errors.Is(err, ErrInvalid) { + t.Errorf("Normalize(%q) = %v; want ErrInvalid", raw, err) + } + } +} diff --git a/services/core/internal/metadata/metadata.go b/services/core/internal/metadata/metadata.go new file mode 100644 index 000000000..ebf539866 --- /dev/null +++ b/services/core/internal/metadata/metadata.go @@ -0,0 +1,112 @@ +// Package metadata owns the rules for resource metadata: string key and value +// pairs that Agents, Sessions and Vaults carry for their applications. +package metadata + +import ( + "encoding/json" + "errors" + "fmt" + "maps" + "slices" + "strings" + "unicode/utf8" +) + +const ( + maxPairs = 16 + maxKeyLength = 64 + maxValueLength = 512 + maxEncoded = 64 * 1024 +) + +// Kind names the rule a metadata map breaks. +type Kind int + +const ( + // TooManyPairs: more than 16 pairs; Length is the pair count. + TooManyPairs Kind = iota + 1 + // KeyTooLong: a key longer than 64 characters; Length is its length. + KeyTooLong + // ValueTooLong: a value longer than 512 characters; Length is its length. + ValueTooLong + // KeyUnstorable: a key contains U+0000. + KeyUnstorable + // ValueUnstorable: a value contains U+0000. + ValueUnstorable +) + +// Violation is the first rule a metadata map breaks. Key is empty for +// TooManyPairs, and Length is the counted size for the length rules. +type Violation struct { + Kind Kind + Key string + Length int +} + +func (v *Violation) Error() string { + switch v.Kind { + case TooManyPairs: + return fmt.Sprintf("metadata has %d pairs; at most %d are allowed", v.Length, maxPairs) + case KeyTooLong: + return fmt.Sprintf("metadata key has %d characters; at most %d are allowed", v.Length, maxKeyLength) + case ValueTooLong: + return fmt.Sprintf("metadata value has %d characters; at most %d are allowed", v.Length, maxValueLength) + case KeyUnstorable: + return "metadata key contains U+0000" + case ValueUnstorable: + return "metadata value contains U+0000" + } + return "invalid metadata" +} + +// ErrTooLarge reports metadata whose JSON encoding exceeds 64 KiB. +var ErrTooLarge = errors.New("metadata exceeds 64 KiB") + +// Validate applies the pinned limits of 16 pairs, 64-character keys and +// 512-character values, then ValidateStorable. Keys are checked in sorted +// order, so the reported violation is stable. +func Validate(metadata map[string]string) error { + if len(metadata) > maxPairs { + return &Violation{Kind: TooManyPairs, Length: len(metadata)} + } + for _, key := range slices.Sorted(maps.Keys(metadata)) { + if length := utf8.RuneCountInString(key); length > maxKeyLength { + return &Violation{Kind: KeyTooLong, Key: key, Length: length} + } + if length := utf8.RuneCountInString(metadata[key]); length > maxValueLength { + return &Violation{Kind: ValueTooLong, Key: key, Length: length} + } + } + return ValidateStorable(metadata) +} + +// ValidateStorable applies only the local U+0000 limit: PostgreSQL text and +// jsonb cannot store it, although the official service accepts it. Keys are +// checked in sorted order. +func ValidateStorable(metadata map[string]string) error { + for _, key := range slices.Sorted(maps.Keys(metadata)) { + if strings.ContainsRune(key, 0) { + return &Violation{Kind: KeyUnstorable, Key: key} + } + if strings.ContainsRune(metadata[key], 0) { + return &Violation{Kind: ValueUnstorable, Key: key} + } + } + return nil +} + +// Encode returns the stored JSON object; nil metadata is stored as {}. +// It enforces only the 64 KiB storage bound, not Validate's rules. +func Encode(metadata map[string]string) ([]byte, error) { + if metadata == nil { + metadata = map[string]string{} + } + encoded, err := json.Marshal(metadata) + if err != nil { + return nil, err + } + if len(encoded) > maxEncoded { + return nil, ErrTooLarge + } + return encoded, nil +} diff --git a/services/core/internal/metadata/metadata_test.go b/services/core/internal/metadata/metadata_test.go new file mode 100644 index 000000000..ad7694c0f --- /dev/null +++ b/services/core/internal/metadata/metadata_test.go @@ -0,0 +1,86 @@ +package metadata + +import ( + "errors" + "fmt" + "reflect" + "strings" + "testing" +) + +func pairs(n int) map[string]string { + result := make(map[string]string, n) + for i := range n { + result[fmt.Sprintf("k%02d", i)] = "v" + } + return result +} + +func TestValidate(t *testing.T) { + for _, test := range []struct { + name string + metadata map[string]string + want *Violation + }{ + {"nil", nil, nil}, + {"boundary pairs", pairs(16), nil}, + {"boundary key", map[string]string{strings.Repeat("雪", 64): "v"}, nil}, + {"boundary value", map[string]string{"k": strings.Repeat("雪", 512)}, nil}, + {"too many pairs", pairs(17), &Violation{Kind: TooManyPairs, Length: 17}}, + {"long key", map[string]string{strings.Repeat("K", 65): "v"}, &Violation{Kind: KeyTooLong, Key: strings.Repeat("K", 65), Length: 65}}, + {"long value", map[string]string{"k": strings.Repeat("雪", 513)}, &Violation{Kind: ValueTooLong, Key: "k", Length: 513}}, + {"sorted keys", map[string]string{"b": strings.Repeat("x", 513), "a": strings.Repeat("x", 514)}, &Violation{Kind: ValueTooLong, Key: "a", Length: 514}}, + {"key before value", map[string]string{strings.Repeat("K", 65): strings.Repeat("x", 513)}, &Violation{Kind: KeyTooLong, Key: strings.Repeat("K", 65), Length: 65}}, + {"length before U+0000", map[string]string{"a": "\x00", "b": strings.Repeat("x", 513)}, &Violation{Kind: ValueTooLong, Key: "b", Length: 513}}, + {"U+0000 key", map[string]string{"a\x00b": "v"}, &Violation{Kind: KeyUnstorable, Key: "a\x00b"}}, + {"U+0000 value", map[string]string{"k": "a\x00b"}, &Violation{Kind: ValueUnstorable, Key: "k"}}, + } { + err := Validate(test.metadata) + if test.want == nil { + if err != nil { + t.Errorf("%s: Validate = %v; want nil", test.name, err) + } + continue + } + var violation *Violation + if !errors.As(err, &violation) || !reflect.DeepEqual(violation, test.want) { + t.Errorf("%s: Validate = %#v; want %#v", test.name, err, test.want) + } + } +} + +func TestValidateStorableIgnoresPinnedLimits(t *testing.T) { + if err := ValidateStorable(pairs(17)); err != nil { + t.Fatalf("pair count applied: %v", err) + } + if err := ValidateStorable(map[string]string{strings.Repeat("K", 65): strings.Repeat("x", 513)}); err != nil { + t.Fatalf("length applied: %v", err) + } + var violation *Violation + if err := ValidateStorable(map[string]string{"k": "a\x00"}); !errors.As(err, &violation) || *violation != (Violation{Kind: ValueUnstorable, Key: "k"}) { + t.Fatalf("U+0000 accepted: %v", err) + } +} + +func TestEncode(t *testing.T) { + for _, test := range []struct { + metadata map[string]string + want string + }{ + {nil, `{}`}, + {map[string]string{}, `{}`}, + {map[string]string{"b": "2", "a": "1"}, `{"a":"1","b":"2"}`}, + } { + got, err := Encode(test.metadata) + if err != nil || string(got) != test.want { + t.Errorf("Encode(%v) = %s, %v; want %s", test.metadata, got, err, test.want) + } + } + // {"large":"…"} adds 12 bytes around the value. + if _, err := Encode(map[string]string{"large": strings.Repeat("x", 64*1024-12)}); err != nil { + t.Fatalf("boundary rejected: %v", err) + } + if _, err := Encode(map[string]string{"large": strings.Repeat("x", 64*1024-11)}); !errors.Is(err, ErrTooLarge) { + t.Fatalf("oversized metadata accepted: %v", err) + } +} diff --git a/services/core/internal/skills/doc.go b/services/core/internal/skills/doc.go new file mode 100644 index 000000000..402a329d3 --- /dev/null +++ b/services/core/internal/skills/doc.go @@ -0,0 +1,2 @@ +// Package skills owns the Skill resource vocabulary and its rules. +package skills diff --git a/services/core/internal/skills/version.go b/services/core/internal/skills/version.go new file mode 100644 index 000000000..7259300c2 --- /dev/null +++ b/services/core/internal/skills/version.go @@ -0,0 +1,20 @@ +package skills + +import ( + "errors" + "strconv" +) + +// ErrInvalidVersion reports a version that is not a canonical positive decimal. +var ErrInvalidVersion = errors.New("skill version must be a canonical positive decimal integer") + +// ParseVersion accepts only the canonical decimal spelling of a positive +// version number, so every version has exactly one text form. Selecting +// "latest" or an omitted version belongs to the caller. +func ParseVersion(value string) (int64, error) { + number, err := strconv.ParseInt(value, 10, 64) + if err != nil || number < 1 || strconv.FormatInt(number, 10) != value { + return 0, ErrInvalidVersion + } + return number, nil +} diff --git a/services/core/internal/skills/version_test.go b/services/core/internal/skills/version_test.go new file mode 100644 index 000000000..e6dcf88ad --- /dev/null +++ b/services/core/internal/skills/version_test.go @@ -0,0 +1,27 @@ +package skills + +import ( + "errors" + "testing" +) + +func TestParseVersion(t *testing.T) { + for _, test := range []struct { + value string + want int64 + }{ + {"1", 1}, + {"42", 42}, + {"9223372036854775807", 9223372036854775807}, + } { + got, err := ParseVersion(test.value) + if err != nil || got != test.want { + t.Errorf("ParseVersion(%q) = %d, %v; want %d", test.value, got, err, test.want) + } + } + for _, value := range []string{"", "0", "-1", "+1", "01", " 1", "1 ", "1.0", "latest", "9223372036854775808"} { + if got, err := ParseVersion(value); !errors.Is(err, ErrInvalidVersion) || got != 0 { + t.Errorf("ParseVersion(%q) = %d, %v; want ErrInvalidVersion", value, got, err) + } + } +} diff --git a/services/core/internal/store/admin_delete_audit_test.go b/services/core/internal/store/admin_delete_audit_test.go index 6d7f94cd9..4dedf9484 100644 --- a/services/core/internal/store/admin_delete_audit_test.go +++ b/services/core/internal/store/admin_delete_audit_test.go @@ -11,6 +11,7 @@ import ( "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/adminaudit" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/credentialcrypto" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/environmentconfig" "github.com/google/uuid" "github.com/jackc/pgx/v5" ) @@ -132,7 +133,7 @@ func TestAdminDeleteResourceAuditTransactions(t *testing.T) { if err := pool.QueryRow(t.Context(), "SELECT id FROM environment_templates WHERE tenant_id=$1", tenant).Scan(&id); err != nil { t.Fatal(err) } - if _, err := s.UpdateEnvironmentTemplate(t.Context(), tenant, id, EnvironmentTemplateInput{SetEnv: true, SetSetup: true, SetFiles: true, Initialization: EnvironmentSetup{Env: map[string]string{"PRIVATE": "admin-private-env"}, Commands: []SetupCommand{{Command: "printf admin-private-env"}}}, Files: []InitialFile{{Type: "inline", Path: "/workspace/private", Data: []byte("admin-private-body")}}}); err != nil { + if _, err := s.UpdateEnvironmentTemplate(t.Context(), tenant, id, EnvironmentTemplateInput{SetEnv: true, SetSetup: true, SetFiles: true, Initialization: environmentconfig.Setup{Env: map[string]string{"PRIVATE": "admin-private-env"}, Commands: []environmentconfig.SetupCommand{{Command: "printf admin-private-env"}}}, Files: []environmentconfig.InitialFile{{Type: "inline", Path: "/workspace/private", Data: []byte("admin-private-body")}}}); err != nil { t.Fatal(err) } } diff --git a/services/core/internal/store/agents.go b/services/core/internal/store/agents.go index d5e880a3d..9713cc98b 100644 --- a/services/core/internal/store/agents.go +++ b/services/core/internal/store/agents.go @@ -13,6 +13,8 @@ import ( "github.com/jackc/pgx/v5/pgtype" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/db/sqlc" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/jsonobject" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/metadata" ) // SavedAgent is reusable configuration owned by an execution tenant. It has no @@ -40,16 +42,16 @@ func (s *Store) CreateAgent(ctx context.Context, tenantID string, input CreateAg if err != nil { return SavedAgent{}, err } - metadata, err := encodeMetadata(input.Metadata) + encodedMetadata, err := metadata.Encode(input.Metadata) if err != nil { - return SavedAgent{}, err + return SavedAgent{}, fmt.Errorf("%w: %w", ErrInvalidInput, err) } if len(input.Configuration) == 0 || len(input.Configuration) > 512*1024 { return SavedAgent{}, fmt.Errorf("%w: configuration must be an object of at most 512 KiB", ErrInvalidInput) } - configuration, err := canonicalJSONObject(input.Configuration) + configuration, err := jsonobject.Normalize(input.Configuration) if err != nil { - return SavedAgent{}, err + return SavedAgent{}, fmt.Errorf("%w: %w", ErrInvalidInput, err) } if err := validateAgentModelExecution(configuration, input.ModelProvider); err != nil { return SavedAgent{}, err @@ -59,7 +61,7 @@ func (s *Store) CreateAgent(ctx context.Context, tenantID string, input CreateAg q := s.queries.WithTx(tx) row, err := q.CreateAgent(ctx, sqlc.CreateAgentParams{ ID: pgtype.UUID{Bytes: uuid.New(), Valid: true}, TenantID: tenant, - Metadata: metadata, Configuration: configuration, + Metadata: encodedMetadata, Configuration: configuration, }) if err != nil { return err diff --git a/services/core/internal/store/agents_test.go b/services/core/internal/store/agents_test.go index c1c6edaa1..ac21b1177 100644 --- a/services/core/internal/store/agents_test.go +++ b/services/core/internal/store/agents_test.go @@ -8,6 +8,7 @@ import ( "testing" "time" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/jsonobject" "github.com/google/uuid" ) @@ -29,11 +30,11 @@ func TestSavedAgentsPersistIndependentlyAndStayTenantScoped(t *testing.T) { if err != nil { t.Fatal(err) } - expectedConfig, err := canonicalJSONObject(input.Configuration) + expectedConfig, err := jsonobject.Normalize(input.Configuration) if err != nil { t.Fatal(err) } - gotConfig, err := canonicalJSONObject(first.Configuration) + gotConfig, err := jsonobject.Normalize(first.Configuration) if err != nil || string(gotConfig) != string(expectedConfig) { t.Fatalf("configuration changed: %s, %v", first.Configuration, err) } diff --git a/services/core/internal/store/agents_update.go b/services/core/internal/store/agents_update.go index 49b315cf9..f748d9601 100644 --- a/services/core/internal/store/agents_update.go +++ b/services/core/internal/store/agents_update.go @@ -8,6 +8,8 @@ import ( v1 "github.com/MiniMax-AI/OpenAgentCore/contracts/agents-api/v1" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/db/sqlc" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/jsonobject" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/metadata" "github.com/google/uuid" "github.com/jackc/pgx/v5" ) @@ -39,19 +41,19 @@ func (s *Store) UpdateAgent(ctx context.Context, tenantID, agentID string, input if len(raw) > 512*1024 { return SavedAgent{}, ErrInvalidInput } - raw, err = canonicalJSONObject(raw) + raw, err = jsonobject.Normalize(raw) if err != nil { - return SavedAgent{}, err + return SavedAgent{}, fmt.Errorf("%w: %w", ErrInvalidInput, err) } var patch map[string]json.RawMessage if err := json.Unmarshal(raw, &patch); err != nil { return SavedAgent{}, err } - var metadata []byte + var encodedMetadata []byte if input.Metadata != nil { - metadata, err = encodeMetadata(*input.Metadata) + encodedMetadata, err = metadata.Encode(*input.Metadata) if err != nil { - return SavedAgent{}, err + return SavedAgent{}, fmt.Errorf("%w: %w", ErrInvalidInput, err) } } var updated SavedAgent @@ -75,9 +77,9 @@ func (s *Store) UpdateAgent(ctx context.Context, tenantID, agentID string, input if err != nil { return err } - merged, err = canonicalJSONObject(merged) + merged, err = jsonobject.Normalize(merged) if err != nil { - return err + return fmt.Errorf("%w: %w", ErrInvalidInput, err) } if len(merged) > 512*1024 { return ErrInvalidInput @@ -91,9 +93,9 @@ func (s *Store) UpdateAgent(ctx context.Context, tenantID, agentID string, input } } if input.Metadata == nil { - metadata = row.Metadata + encodedMetadata = row.Metadata } - row, err = q.UpdateAgent(ctx, sqlc.UpdateAgentParams{TenantID: tenant, ID: id, Configuration: merged, Metadata: metadata}) + row, err = q.UpdateAgent(ctx, sqlc.UpdateAgentParams{TenantID: tenant, ID: id, Configuration: merged, Metadata: encodedMetadata}) if err != nil { return err } diff --git a/services/core/internal/store/environment_initialization_test.go b/services/core/internal/store/environment_initialization_test.go index 446445bc6..5f8a3c360 100644 --- a/services/core/internal/store/environment_initialization_test.go +++ b/services/core/internal/store/environment_initialization_test.go @@ -19,6 +19,7 @@ import ( "testing" "time" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/environmentconfig" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/store" ) @@ -56,8 +57,8 @@ func TestUserManagedPreparationUsesAuthenticatedRuntimeWithoutAllocation(t *test session, err := s.CreateSession(t.Context(), principal.TenantID, store.CreateSessionInput{ Creator: principal.Subject(), Engine: "codex", IdempotencyKey: uuid.NewString(), Configuration: json.RawMessage(`{"environment":{"type":"self_hosted","workspace_directory":"/home/user/work"}}`), - InitialFiles: []store.InitialFile{{Type: "inline", Path: "/workspace/input", Data: []byte("frozen")}}, - Initialization: store.EnvironmentSetup{Skills: []store.EnvironmentSkill{hostedFailureSkill(t)}, Env: map[string]string{"EXPLICIT": "value"}, Commands: []store.SetupCommand{{Command: "touch setup"}}, CapabilityDirectories: []string{"/home/user/capabilities"}}, + InitialFiles: []environmentconfig.InitialFile{{Type: "inline", Path: "/workspace/input", Data: []byte("frozen")}}, + Initialization: environmentconfig.Setup{Skills: []environmentconfig.Skill{hostedFailureSkill(t)}, Env: map[string]string{"EXPLICIT": "value"}, Commands: []environmentconfig.SetupCommand{{Command: "touch setup"}}, CapabilityDirectories: []string{"/home/user/capabilities"}}, }) if err != nil { t.Fatal(err) @@ -177,7 +178,7 @@ func TestEnvironmentInitializationRevocationBeforeClaim(t *testing.T) { session, err := s.CreateSession(t.Context(), principal.TenantID, store.CreateSessionInput{ Creator: principal.Subject(), Engine: "codex", IdempotencyKey: uuid.NewString(), Configuration: json.RawMessage(`{"environment":{"type":"self_hosted","workspace_directory":"/home/user/work"}}`), - InitialFiles: []store.InitialFile{{Type: "inline", Path: "/workspace/input", Data: []byte("frozen")}}, + InitialFiles: []environmentconfig.InitialFile{{Type: "inline", Path: "/workspace/input", Data: []byte("frozen")}}, }) if err != nil { t.Fatal(err) diff --git a/services/core/internal/store/environment_packages_test.go b/services/core/internal/store/environment_packages_test.go index c3c128068..c4c9d94e8 100644 --- a/services/core/internal/store/environment_packages_test.go +++ b/services/core/internal/store/environment_packages_test.go @@ -5,6 +5,7 @@ import ( "testing" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/credentialcrypto" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/environmentconfig" "github.com/google/uuid" ) @@ -27,7 +28,7 @@ func TestStoredSystemPackagesRejected(t *testing.T) { if err != nil { t.Fatal(err) } - var output EnvironmentSetup + var output environmentconfig.Setup if err := s.openEnvironmentSetup(tenant, "session", session, "initialization", encrypted, &output); err == nil { t.Fatal("snapshot silently ignored removed system packages", value) } diff --git a/services/core/internal/store/environment_plugins.go b/services/core/internal/store/environment_plugins.go index 4bae93c3e..ee26a9bde 100644 --- a/services/core/internal/store/environment_plugins.go +++ b/services/core/internal/store/environment_plugins.go @@ -2,52 +2,11 @@ package store import ( "encoding/json" - "github.com/MiniMax-AI/OpenAgentCore/internal/agentplugin" -) - -const MaxPluginsArchiveBytes = 10 << 20 - -// EnvironmentPlugin separates safe identity from confidential immutable input. -type EnvironmentPlugin struct { - Metadata agentplugin.Metadata `json:"metadata"` - Archive []byte `json:"archive"` -} -func ValidateEnvironmentPlugins(plugins []EnvironmentPlugin) error { - if len(plugins) > 50 { - return ErrInvalidInput - } - seen := map[string]bool{} - compressed, expanded := 0, 0 - for _, plugin := range plugins { - compressed += len(plugin.Archive) - if compressed > MaxPluginsArchiveBytes || seen[plugin.Metadata.Name] { - return ErrInvalidInput - } - seen[plugin.Metadata.Name] = true - bundle, err := agentplugin.Read(plugin.Archive, plugin.Metadata) - if err != nil { - return ErrInvalidInput - } - for _, file := range bundle.Files { - expanded += len(file.Data) - } - if expanded > 50<<20 { - return ErrInvalidInput - } - } - return nil -} - -func (s EnvironmentSetup) PluginMetadata() []agentplugin.Metadata { - result := make([]agentplugin.Metadata, 0, len(s.Plugins)) - for _, plugin := range s.Plugins { - result = append(result, plugin.Metadata) - } - return result -} + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/environmentconfig" +) -func (s *Store) sealTemplatePlugins(tenant, id string, setup EnvironmentSetup) ([]byte, []byte, error) { +func (s *Store) sealTemplatePlugins(tenant, id string, setup environmentconfig.Setup) ([]byte, []byte, error) { metadata, err := json.Marshal(setup.PluginMetadata()) if err != nil { return nil, nil, err diff --git a/services/core/internal/store/environment_plugins_test.go b/services/core/internal/store/environment_plugins_test.go index 68f217a74..a73daaa81 100644 --- a/services/core/internal/store/environment_plugins_test.go +++ b/services/core/internal/store/environment_plugins_test.go @@ -10,6 +10,7 @@ import ( "github.com/MiniMax-AI/OpenAgentCore/internal/agentplugin" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/credentialcrypto" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/environmentconfig" "github.com/google/uuid" ) @@ -38,7 +39,7 @@ func TestPluginsEncryptedTemplateAndFrozenSession(t *testing.T) { if err = writer.Close(); err != nil { t.Fatal(err) } - setup := EnvironmentSetup{Plugins: []EnvironmentPlugin{{Metadata: agentplugin.Metadata{Type: "inline", Name: "plugin-proof", Description: "A proof."}, Archive: archive.Bytes()}}, CapabilityDirectories: []string{"/workspace/generated"}} + setup := environmentconfig.Setup{Plugins: []environmentconfig.Plugin{{Metadata: agentplugin.Metadata{Type: "inline", Name: "plugin-proof", Description: "A proof."}, Archive: archive.Bytes()}}, CapabilityDirectories: []string{"/workspace/generated"}} tenant, foreign := uuid.NewString(), uuid.NewString() template, err := s.CreateEnvironmentTemplate(t.Context(), tenant, EnvironmentTemplateInput{SetPlugins: true, SetDirectories: true, Initialization: setup}) if err != nil { diff --git a/services/core/internal/store/environment_setup.go b/services/core/internal/store/environment_setup.go index b123d4f0c..0eb3dec25 100644 --- a/services/core/internal/store/environment_setup.go +++ b/services/core/internal/store/environment_setup.go @@ -1,82 +1,18 @@ package store import ( - "bytes" "context" "encoding/json" "errors" - "path" - "regexp" - "strings" - v1 "github.com/MiniMax-AI/OpenAgentCore/contracts/agents-api/v1" - "github.com/MiniMax-AI/OpenAgentCore/internal/agentcapabilities" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/credentialcrypto" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/db/sqlc" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/environmentconfig" "github.com/google/uuid" "github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5/pgtype" ) -// EnvironmentSetup is confidential input, never ordinary resource metadata. -// Core freezes it once; the common Runtime initializer executes it in order. -type EnvironmentSetup struct { - Env map[string]string `json:"env,omitempty"` - Commands []SetupCommand `json:"setup_commands,omitempty"` - Packages v1.EnvironmentPackages `json:"packages"` - Skills []EnvironmentSkill `json:"skills,omitempty"` - Plugins []EnvironmentPlugin `json:"plugins,omitempty"` - CapabilityDirectories []string `json:"capability_directories,omitempty"` -} - -type SetupCommand struct { - Command string `json:"command"` - CWD string `json:"cwd,omitempty"` -} - -var environmentName = regexp.MustCompile(`^[A-Za-z_][A-Za-z0-9_]*$`) - -func (s EnvironmentSetup) Empty() bool { - return len(s.Env)+len(s.Commands)+len(s.Packages.NPM)+len(s.Packages.Python)+len(s.Skills)+len(s.Plugins)+len(s.CapabilityDirectories) == 0 -} - -func (s EnvironmentSetup) Validate() error { - return s.validate(false) -} - -func (s EnvironmentSetup) validate(installed bool) error { - if validateEnvironmentSkills(s.Skills, installed) != nil || ValidateEnvironmentPlugins(s.Plugins) != nil || agentcapabilities.ValidateSourceDirectories(s.CapabilityDirectories) != nil { - return ErrInvalidInput - } - ordinary := s - ordinary.Skills = nil - ordinary.Plugins = nil - raw, err := json.Marshal(ordinary) - if err != nil || len(raw) > 512*1024 { - return ErrInvalidInput - } - for name, value := range s.Env { - // The first three reservations are explicitly part of the public guide; - // OAC_* identifies the actual deployment authority and binding. - if !environmentName.MatchString(name) || name == "PATH" || name == "OPENAI_API_KEY" || strings.HasPrefix(name, "CODEX_") || strings.HasPrefix(name, "OAC_") || strings.ContainsRune(value, 0) { - return ErrInvalidInput - } - } - for _, command := range s.Commands { - if command.Command == "" || strings.ContainsRune(command.Command, 0) || (command.CWD != "" && (!path.IsAbs(command.CWD) || strings.ContainsRune(command.CWD, 0))) { - return ErrInvalidInput - } - } - for _, packages := range [][]string{s.Packages.NPM, s.Packages.Python} { - for _, item := range packages { - if item == "" || strings.HasPrefix(item, "-") || strings.ContainsRune(item, 0) { - return ErrInvalidInput - } - } - } - return nil -} - func (s *Store) sealEnvironmentSetup(tenant, resource, id, field string, input any, empty bool) ([]byte, error) { if empty { return nil, nil @@ -96,15 +32,15 @@ func (s *Store) openEnvironmentSetup(tenant, resource, id, field string, ciphert if err != nil { return err } - if decodeSetupJSON(plaintext, output) != nil { + if environmentconfig.Decode(plaintext, output) != nil { return ErrInvalidInput } return nil } -func (s *Store) saveEnvironmentSetup(ctx context.Context, q *sqlc.Queries, tenant string, session pgtype.UUID, setup EnvironmentSetup) error { - if err := setup.validate(true); err != nil { - return err +func (s *Store) saveEnvironmentSetup(ctx context.Context, q *sqlc.Queries, tenant string, session pgtype.UUID, setup environmentconfig.Setup) error { + if setup.ValidateInstalled() != nil { + return ErrInvalidInput } if setup.Empty() { return nil @@ -120,8 +56,8 @@ func (s *Store) saveEnvironmentSetup(ctx context.Context, q *sqlc.Queries, tenan return q.CreateEnvironmentSetup(ctx, sqlc.CreateEnvironmentSetupParams{SessionID: session, Contents: encrypted}) } -func (s *Store) ReadEnvironmentSetup(ctx context.Context, tenant, session string) (EnvironmentSetup, error) { - var result EnvironmentSetup +func (s *Store) ReadEnvironmentSetup(ctx context.Context, tenant, session string) (environmentconfig.Setup, error) { + var result environmentconfig.Setup lookup, err := deviceLookup(tenant, session) if err != nil { return result, ErrNotFound @@ -136,21 +72,13 @@ func (s *Store) ReadEnvironmentSetup(ctx context.Context, tenant, session string if err = s.openEnvironmentSetup(uuid.UUID(lookup.TenantID.Bytes).String(), "session", uuid.UUID(lookup.ID.Bytes).String(), "initialization", encrypted, &result); err != nil { return result, err } - return result, result.validate(true) -} - -func (s EnvironmentSetup) PackageMetadata() v1.EnvironmentPackages { - result := s.Packages - if result.NPM == nil { - result.NPM = []string{} - } - if result.Python == nil { - result.Python = []string{} + if result.ValidateInstalled() != nil { + return result, ErrInvalidInput } - return result + return result, nil } -func (s *Store) sealTemplateSetup(tenant, id string, setup EnvironmentSetup) ([]byte, []byte, []byte, error) { +func (s *Store) sealTemplateSetup(tenant, id string, setup environmentconfig.Setup) ([]byte, []byte, []byte, error) { packages, err := json.Marshal(setup.PackageMetadata()) if err != nil { return nil, nil, nil, err @@ -162,13 +90,3 @@ func (s *Store) sealTemplateSetup(tenant, id string, setup EnvironmentSetup) ([] commands, err := s.sealEnvironmentSetup(tenant, "environment_template", id, "setup_commands", setup.Commands, len(setup.Commands) == 0) return packages, env, commands, err } - -// decodeSetupJSON rejects removed configuration fields instead of silently dropping them. -func decodeSetupJSON(data []byte, output any) error { - if !json.Valid(data) { - return ErrInvalidInput - } - decoder := json.NewDecoder(bytes.NewReader(data)) - decoder.DisallowUnknownFields() - return decoder.Decode(output) -} diff --git a/services/core/internal/store/environment_setup_test.go b/services/core/internal/store/environment_setup_test.go index b4a5d8320..cbc42c335 100644 --- a/services/core/internal/store/environment_setup_test.go +++ b/services/core/internal/store/environment_setup_test.go @@ -9,6 +9,7 @@ import ( v1 "github.com/MiniMax-AI/OpenAgentCore/contracts/agents-api/v1" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/credentialcrypto" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/environmentconfig" "github.com/google/uuid" ) @@ -20,7 +21,7 @@ func TestEnvironmentSetupEncryptedSnapshotAndIsolation(t *testing.T) { } s := NewWithCredentialCipher(pool, cipher) tenant, foreign := uuid.NewString(), uuid.NewString() - setup := EnvironmentSetup{Env: map[string]string{"SECRET": "template-env-canary"}, Commands: []SetupCommand{{Command: "printf template-command-canary > result"}}, Packages: v1.EnvironmentPackages{NPM: []string{"is-number@7.0.0"}}} + setup := environmentconfig.Setup{Env: map[string]string{"SECRET": "template-env-canary"}, Commands: []environmentconfig.SetupCommand{{Command: "printf template-command-canary > result"}}, Packages: v1.EnvironmentPackages{NPM: []string{"is-number@7.0.0"}}} template, err := s.CreateEnvironmentTemplate(t.Context(), tenant, EnvironmentTemplateInput{Initialization: setup, SetEnv: true, SetSetup: true, SetPackages: true}) if err != nil { t.Fatal(err) @@ -86,14 +87,3 @@ func TestEnvironmentSetupEncryptedSnapshotAndIsolation(t *testing.T) { t.Fatal("uninitialized execution admitted", err) } } - -func TestEnvironmentSetupReservesOpenAgentCoreNames(t *testing.T) { - for _, name := range []string{"OAC_ADDR", "OAC_RUNTIME_HOME", "OAC_WEB_ORIGIN", "OAC_LOG_LEVEL", "OAC_DEV_HOME", "OAC_TEST_DATABASE_URL"} { - if err := (EnvironmentSetup{Env: map[string]string{name: "value"}}).Validate(); !errors.Is(err, ErrInvalidInput) { - t.Fatalf("reserved name %s accepted: %v", name, err) - } - } - if err := (EnvironmentSetup{Env: map[string]string{"APPLICATION_VALUE": "ok"}}).Validate(); err != nil { - t.Fatalf("ordinary application settings rejected: %v", err) - } -} diff --git a/services/core/internal/store/environment_skill_references.go b/services/core/internal/store/environment_skill_references.go index 1cc8e31d8..37ac91428 100644 --- a/services/core/internal/store/environment_skill_references.go +++ b/services/core/internal/store/environment_skill_references.go @@ -7,15 +7,17 @@ import ( "strconv" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/db/sqlc" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/environmentconfig" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/skills" "github.com/jackc/pgx/v5" ) // freezeEnvironmentSkills runs only for a newly inserted Session, in its transaction. // Resource locks serialize selection with pointer changes, version deletion and // resource deletion. The returned copy no longer depends on any source resource. -func (s *Store) freezeEnvironmentSkills(ctx context.Context, q *sqlc.Queries, tenantID string, setup EnvironmentSetup) (EnvironmentSetup, error) { - if err := setup.Validate(); err != nil { - return EnvironmentSetup{}, err +func (s *Store) freezeEnvironmentSkills(ctx context.Context, q *sqlc.Queries, tenantID string, setup environmentconfig.Setup) (environmentconfig.Setup, error) { + if setup.Validate() != nil { + return environmentconfig.Setup{}, ErrInvalidInput } owners := make(map[string]sqlc.Skill) for _, skill := range setup.Skills { @@ -32,19 +34,19 @@ func (s *Store) freezeEnvironmentSkills(ctx context.Context, q *sqlc.Queries, te for _, id := range ids { tenant, skill, err := skillIDs(tenantID, id) if err != nil { - return EnvironmentSetup{}, err + return environmentconfig.Setup{}, err } owner, err := q.LockSkill(ctx, sqlc.LockSkillParams{TenantID: tenant, ID: skill}) if errors.Is(err, pgx.ErrNoRows) { err = ErrNotFound } if err != nil { - return EnvironmentSetup{}, err + return environmentconfig.Setup{}, err } owners[id] = owner } result := setup - result.Skills = append([]EnvironmentSkill(nil), setup.Skills...) + result.Skills = append([]environmentconfig.Skill(nil), setup.Skills...) for i, skill := range result.Skills { if skill.Metadata.Type != "skill_reference" { continue @@ -57,9 +59,9 @@ func (s *Store) freezeEnvironmentSkills(ctx context.Context, q *sqlc.Queries, te number = owner.LatestVersion default: var err error - number, err = skillVersionNumber(skill.Metadata.Version) + number, err = skills.ParseVersion(skill.Metadata.Version) if err != nil { - return EnvironmentSetup{}, err + return environmentconfig.Setup{}, ErrInvalidInput } } row, err := q.ReadSkillVersion(ctx, sqlc.ReadSkillVersionParams{TenantID: owner.TenantID, SkillID: owner.ID, Version: number}) @@ -67,13 +69,16 @@ func (s *Store) freezeEnvironmentSkills(ctx context.Context, q *sqlc.Queries, te err = ErrNotFound } if err != nil { - return EnvironmentSetup{}, err + return environmentconfig.Setup{}, err } version, archive, err := s.openSkillVersion(row) if err != nil { - return EnvironmentSetup{}, err + return environmentconfig.Setup{}, err } - result.Skills[i] = EnvironmentSkill{Metadata: EnvironmentSkillMetadata{Type: "skill_reference", SkillID: version.SkillID, Version: strconv.FormatInt(version.Version, 10), Name: version.Name, Description: version.Description}, Archive: archive} + result.Skills[i] = environmentconfig.Skill{Metadata: environmentconfig.SkillMetadata{Type: "skill_reference", SkillID: version.SkillID, Version: strconv.FormatInt(version.Version, 10), Name: version.Name, Description: version.Description}, Archive: archive} } - return result, result.validate(true) + if result.ValidateInstalled() != nil { + return environmentconfig.Setup{}, ErrInvalidInput + } + return result, nil } diff --git a/services/core/internal/store/environment_skill_references_test.go b/services/core/internal/store/environment_skill_references_test.go index eb6ea4ea2..3c1ead0a1 100644 --- a/services/core/internal/store/environment_skill_references_test.go +++ b/services/core/internal/store/environment_skill_references_test.go @@ -8,6 +8,7 @@ import ( "testing" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/credentialcrypto" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/environmentconfig" "github.com/google/uuid" ) @@ -27,7 +28,7 @@ func TestSkillReferencesFreezeWithinSessionCreation(t *testing.T) { if _, err = s.CreateSkillVersion(t.Context(), tenant, skill.ID, second, false); err != nil { t.Fatal(err) } - intent := EnvironmentSetup{Skills: []EnvironmentSkill{{Metadata: EnvironmentSkillMetadata{Type: "skill_reference", SkillID: skill.ID}}}} + intent := environmentconfig.Setup{Skills: []environmentconfig.Skill{{Metadata: environmentconfig.SkillMetadata{Type: "skill_reference", SkillID: skill.ID}}}} template, err := s.CreateEnvironmentTemplate(t.Context(), tenant, EnvironmentTemplateInput{SetSkills: true, Initialization: intent}) if err != nil { t.Fatal(err) @@ -78,7 +79,7 @@ func TestSkillReferencesFreezeWithinSessionCreation(t *testing.T) { } var cfg struct { Environment struct { - Skills []EnvironmentSkillMetadata `json:"skills"` + Skills []environmentconfig.SkillMetadata `json:"skills"` } `json:"environment"` } if json.Unmarshal(session.Configuration, &cfg) != nil || len(cfg.Environment.Skills) != 1 || cfg.Environment.Skills[0] != setup.Skills[0].Metadata || bytes.Contains(session.Configuration, archive) { @@ -88,7 +89,7 @@ func TestSkillReferencesFreezeWithinSessionCreation(t *testing.T) { assertFrozen(sessionID, "1", first) latest := input latest.IdempotencyKey = uuid.NewString() - latest.Initialization.Skills = []EnvironmentSkill{{Metadata: EnvironmentSkillMetadata{Type: "skill_reference", SkillID: skill.ID, Version: "latest"}}} + latest.Initialization.Skills = []environmentconfig.Skill{{Metadata: environmentconfig.SkillMetadata{Type: "skill_reference", SkillID: skill.ID, Version: "latest"}}} latestSession, err := s.CreateSession(t.Context(), tenant, latest) if err != nil { t.Fatal(err) @@ -103,7 +104,7 @@ func TestSkillReferencesFreezeWithinSessionCreation(t *testing.T) { for _, selector := range []string{"", "latest", "1"} { next := input next.IdempotencyKey = uuid.NewString() - next.Initialization.Skills = []EnvironmentSkill{{Metadata: EnvironmentSkillMetadata{Type: "skill_reference", SkillID: skill.ID, Version: selector}}} + next.Initialization.Skills = []environmentconfig.Skill{{Metadata: environmentconfig.SkillMetadata{Type: "skill_reference", SkillID: skill.ID, Version: selector}}} created, err := s.CreateSession(t.Context(), tenant, next) if err != nil { t.Fatal(err) @@ -142,7 +143,7 @@ func TestSkillReferenceAuthorizationRollsBackSession(t *testing.T) { if err != nil { t.Fatal(err) } - input := CreateSessionInput{Creator: FixtureCreator(), Engine: "codex", IdempotencyKey: uuid.NewString(), Configuration: json.RawMessage(`{"environment":{"type":"openai_hosted"}}`), Initialization: EnvironmentSetup{Skills: []EnvironmentSkill{{Metadata: EnvironmentSkillMetadata{Type: "skill_reference", SkillID: skill.ID}}}}} + input := CreateSessionInput{Creator: FixtureCreator(), Engine: "codex", IdempotencyKey: uuid.NewString(), Configuration: json.RawMessage(`{"environment":{"type":"openai_hosted"}}`), Initialization: environmentconfig.Setup{Skills: []environmentconfig.Skill{{Metadata: environmentconfig.SkillMetadata{Type: "skill_reference", SkillID: skill.ID}}}}} if _, err := s.CreateSession(t.Context(), foreign, input); !errors.Is(err, ErrNotFound) { t.Fatal("foreign reference accepted", err) } diff --git a/services/core/internal/store/environment_skills.go b/services/core/internal/store/environment_skills.go index 1033f7b92..33cccf315 100644 --- a/services/core/internal/store/environment_skills.go +++ b/services/core/internal/store/environment_skills.go @@ -3,110 +3,10 @@ package store import ( "encoding/json" - "github.com/MiniMax-AI/OpenAgentCore/internal/agentskill" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/environmentconfig" ) -// EnvironmentSkillMetadata is either template intent or concrete installed metadata. -// References acquire their name, description and concrete version at Session commit. -type EnvironmentSkillMetadata struct { - Type string `json:"type"` - Name string `json:"name,omitempty"` - Description string `json:"description,omitempty"` - SkillID string `json:"skill_id,omitempty"` - Version string `json:"version,omitempty"` -} - -// EnvironmentSkill keeps confidential content separate from public metadata. -// A template reference has no archive; a committed Session always has frozen bytes. -type EnvironmentSkill struct { - Metadata EnvironmentSkillMetadata `json:"metadata"` - Archive []byte `json:"archive,omitempty"` -} - -// InstallationMetadata removes public reference identity at the installer boundary. -func (s EnvironmentSkill) InstallationMetadata() agentskill.Metadata { - return agentskill.Metadata{Type: "inline", Name: s.Metadata.Name, Description: s.Metadata.Description} -} - -const MaxSkillsArchiveBytes = 10 << 20 - -func ValidateEnvironmentSkills(skills []EnvironmentSkill) error { - return validateEnvironmentSkills(skills, false) -} - -func ValidateInstalledSkillMetadata(metadata EnvironmentSkillMetadata) error { - if metadata.Name == "" || metadata.Description == "" { - return ErrInvalidInput - } - switch metadata.Type { - case "inline": - if metadata.SkillID != "" || metadata.Version != "" { - return ErrInvalidInput - } - case "skill_reference": - if metadata.SkillID == "" { - return ErrInvalidInput - } - if _, err := skillVersionNumber(metadata.Version); err != nil { - return err - } - default: - return ErrInvalidInput - } - return nil -} - -func validateEnvironmentSkills(skills []EnvironmentSkill, installed bool) error { - if len(skills) > 50 { - return ErrInvalidInput - } - seen := map[string]bool{} - total := 0 - expanded := 0 - for _, skill := range skills { - if !installed && skill.Metadata.Type == "skill_reference" { - m := skill.Metadata - if m.SkillID == "" || len(m.SkillID) > 256 || m.Name != "" || m.Description != "" || len(skill.Archive) != 0 { - return ErrInvalidInput - } - if m.Version != "" && m.Version != "latest" { - if _, err := skillVersionNumber(m.Version); err != nil { - return err - } - } - continue - } - if err := ValidateInstalledSkillMetadata(skill.Metadata); err != nil { - return err - } - total += len(skill.Archive) - if total > MaxSkillsArchiveBytes || seen[skill.Metadata.Name] { - return ErrInvalidInput - } - seen[skill.Metadata.Name] = true - files, err := agentskill.Read(skill.Archive, skill.InstallationMetadata()) - if err != nil { - return ErrInvalidInput - } - for _, file := range files { - expanded += len(file.Data) - } - if expanded > 50<<20 { - return ErrInvalidInput - } - } - return nil -} - -func (s EnvironmentSetup) SkillMetadata() []EnvironmentSkillMetadata { - result := make([]EnvironmentSkillMetadata, 0, len(s.Skills)) - for _, skill := range s.Skills { - result = append(result, skill.Metadata) - } - return result -} - -func (s *Store) sealTemplateSkills(tenant, id string, setup EnvironmentSetup) ([]byte, []byte, error) { +func (s *Store) sealTemplateSkills(tenant, id string, setup environmentconfig.Setup) ([]byte, []byte, error) { metadata, err := json.Marshal(setup.SkillMetadata()) if err != nil { return nil, nil, err diff --git a/services/core/internal/store/environment_skills_test.go b/services/core/internal/store/environment_skills_test.go index 37e02cf87..646eef3d9 100644 --- a/services/core/internal/store/environment_skills_test.go +++ b/services/core/internal/store/environment_skills_test.go @@ -9,6 +9,7 @@ import ( "testing" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/credentialcrypto" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/environmentconfig" "github.com/google/uuid" ) @@ -32,7 +33,7 @@ func TestSkillsEncryptedTemplateAndFrozenSession(t *testing.T) { if err = writer.Close(); err != nil { t.Fatal(err) } - setup := EnvironmentSetup{Skills: []EnvironmentSkill{{Metadata: EnvironmentSkillMetadata{Type: "inline", Name: "proof", Description: "A proof."}, Archive: archive.Bytes()}}} + setup := environmentconfig.Setup{Skills: []environmentconfig.Skill{{Metadata: environmentconfig.SkillMetadata{Type: "inline", Name: "proof", Description: "A proof."}, Archive: archive.Bytes()}}} tenant, foreign := uuid.NewString(), uuid.NewString() template, err := s.CreateEnvironmentTemplate(t.Context(), tenant, EnvironmentTemplateInput{SetSkills: true, Initialization: setup}) if err != nil { diff --git a/services/core/internal/store/environment_templates.go b/services/core/internal/store/environment_templates.go index 0301d8236..68f74aac7 100644 --- a/services/core/internal/store/environment_templates.go +++ b/services/core/internal/store/environment_templates.go @@ -12,6 +12,7 @@ import ( v1 "github.com/MiniMax-AI/OpenAgentCore/contracts/agents-api/v1" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/db/sqlc" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/environmentconfig" "github.com/google/uuid" "github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5/pgtype" @@ -22,10 +23,10 @@ import ( type EnvironmentTemplate struct { Plugins []agentplugin.Metadata CapabilityDirectories []string - Skills []EnvironmentSkillMetadata + Skills []environmentconfig.SkillMetadata Packages v1.EnvironmentPackages - Initialization EnvironmentSetup - Files []InitialFileMetadata + Initialization environmentconfig.Setup + Files []environmentconfig.InitialFileMetadata ID string Name *string NetworkAccess string @@ -35,9 +36,9 @@ type EnvironmentTemplate struct { } type EnvironmentTemplateInput struct { - Initialization EnvironmentSetup + Initialization environmentconfig.Setup SetEnv, SetSetup, SetPackages, SetSkills, SetPlugins, SetDirectories bool - Files []InitialFile + Files []environmentconfig.InitialFile SetFiles bool Name *string SetName bool @@ -64,7 +65,7 @@ func templateFromRow(row templateMetadataRow, err error) (EnvironmentTemplate, e if row.Name.Valid { result.Name = &row.Name.String } - if json.Unmarshal(row.Files, &result.Files) != nil || decodeSetupJSON(row.Packages, &result.Packages) != nil || json.Unmarshal(row.Skills, &result.Skills) != nil || json.Unmarshal(row.Plugins, &result.Plugins) != nil { + if json.Unmarshal(row.Files, &result.Files) != nil || environmentconfig.Decode(row.Packages, &result.Packages) != nil || json.Unmarshal(row.Skills, &result.Skills) != nil || json.Unmarshal(row.Plugins, &result.Plugins) != nil { return EnvironmentTemplate{}, ErrInvalidInput } return result, nil diff --git a/services/core/internal/store/environment_templates_noop_test.go b/services/core/internal/store/environment_templates_noop_test.go index 217b37bb3..b267ef90f 100644 --- a/services/core/internal/store/environment_templates_noop_test.go +++ b/services/core/internal/store/environment_templates_noop_test.go @@ -7,6 +7,7 @@ import ( "testing" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/credentialcrypto" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/environmentconfig" "github.com/google/uuid" ) @@ -20,10 +21,10 @@ func TestTemplateEmptyUpdateTouchesTimeWithoutDecryptingOrChangingContents(t *te tenant, name := uuid.NewString(), "Retained template" original, err := s.CreateEnvironmentTemplate(t.Context(), tenant, EnvironmentTemplateInput{ Name: &name, - Files: []InitialFile{{Type: "inline", Path: "/workspace/input.txt", Data: []byte("file-canary")}}, - Initialization: EnvironmentSetup{ + Files: []environmentconfig.InitialFile{{Type: "inline", Path: "/workspace/input.txt", Data: []byte("file-canary")}}, + Initialization: environmentconfig.Setup{ Env: map[string]string{"PRIVATE_SETUP": "env-canary"}, - Commands: []SetupCommand{{Command: "echo setup-canary"}}, + Commands: []environmentconfig.SetupCommand{{Command: "echo setup-canary"}}, }, }) if err != nil { diff --git a/services/core/internal/store/environments.go b/services/core/internal/store/environments.go index a61b42413..d9b9f9bb0 100644 --- a/services/core/internal/store/environments.go +++ b/services/core/internal/store/environments.go @@ -12,6 +12,7 @@ import ( "github.com/jackc/pgx/v5/pgtype" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/db/sqlc" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/jsonobject" ) // Environment retains execution ownership; its configuration is an internal snapshot, not a public response. @@ -77,9 +78,9 @@ func environmentFromRow(row sqlc.Environment, tenant pgtype.UUID, configuration if err != nil { return Environment{}, fmt.Errorf("get environment: %w", err) } - configuration, err = canonicalJSONObject(configuration) + configuration, err = jsonobject.Normalize(configuration) if err != nil { - return Environment{}, fmt.Errorf("decode environment configuration: %w", err) + return Environment{}, fmt.Errorf("decode environment configuration: %w: %w", ErrInvalidInput, err) } return Environment{ ID: uuid.UUID(row.ID.Bytes).String(), SessionID: uuid.UUID(row.SessionID.Bytes).String(), diff --git a/services/core/internal/store/environments_test.go b/services/core/internal/store/environments_test.go index a19ac2f16..d26daeb42 100644 --- a/services/core/internal/store/environments_test.go +++ b/services/core/internal/store/environments_test.go @@ -10,6 +10,7 @@ import ( "sync" "testing" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/jsonobject" "github.com/google/uuid" ) @@ -135,7 +136,7 @@ func TestEnvironmentCreationWinnerOwnsSnapshotAndIdentity(t *testing.T) { if err := json.Unmarshal(got.creation.Session.Configuration, &snapshot); err != nil { t.Fatal(err) } - canonical, err := canonicalJSONObject(snapshot.Environment) + canonical, err := jsonobject.Normalize(snapshot.Environment) if err != nil || string(canonical) != string(got.environment.Configuration) { t.Fatal("configuration diverged", err) } diff --git a/services/core/internal/store/function_calls_test.go b/services/core/internal/store/function_calls_test.go index 6249282ee..ea224b6ab 100644 --- a/services/core/internal/store/function_calls_test.go +++ b/services/core/internal/store/function_calls_test.go @@ -10,6 +10,7 @@ import ( "sync" "testing" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/jsonobject" "github.com/google/uuid" ) @@ -64,8 +65,8 @@ func TestFunctionCallsPersistCompleteResultsAndReceipts(t *testing.T) { if err != nil || row.Applied || row.ExecutorCallID != "native-"+id || !strings.Contains(string(row.Arguments), "9007199254740993") { t.Fatal(row, err) } - normalized, err := canonicalJSONObject(row.Result) - wanted, _ := canonicalJSONObject(json.RawMessage(expected)) + normalized, err := jsonobject.Normalize(row.Result) + wanted, _ := jsonobject.Normalize(json.RawMessage(expected)) if err != nil || string(normalized) != string(wanted) { t.Fatal("result changed", string(row.Result), err) } diff --git a/services/core/internal/store/function_inputs.go b/services/core/internal/store/function_inputs.go index c25ddb91c..a685e75b6 100644 --- a/services/core/internal/store/function_inputs.go +++ b/services/core/internal/store/function_inputs.go @@ -4,8 +4,10 @@ import ( "context" "encoding/json" "errors" + "fmt" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/db/sqlc" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/jsonobject" "github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5/pgtype" ) @@ -35,9 +37,9 @@ func functionInput(raw json.RawMessage) (FunctionResultInput, error) { if json.Unmarshal(raw, &input) != nil || input.TurnID == "" || !validFunctionIdentity(input.CallID) || len(input.Result) == 0 { return input, ErrInvalidInput } - result, err := canonicalJSONObject(input.Result) + result, err := jsonobject.Normalize(input.Result) if err != nil { - return input, err + return input, fmt.Errorf("%w: %w", ErrInvalidInput, err) } input.Result = result return input, nil diff --git a/services/core/internal/store/function_inputs_test.go b/services/core/internal/store/function_inputs_test.go index 8a97c3ef4..b9eb35a12 100644 --- a/services/core/internal/store/function_inputs_test.go +++ b/services/core/internal/store/function_inputs_test.go @@ -8,6 +8,7 @@ import ( "sync" "testing" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/jsonobject" "github.com/google/uuid" ) @@ -48,8 +49,8 @@ func TestFunctionInputBatchesPersistAndReplayWithoutRetargeting(t *testing.T) { } } call, err := s.GetFunctionCall(t.Context(), tenant, session.ID, turn, "a") - got, _ := canonicalJSONObject(call.Result) - want, _ := canonicalJSONObject(json.RawMessage(full)) + got, _ := jsonobject.Normalize(call.Result) + want, _ := jsonobject.Normalize(json.RawMessage(full)) if err != nil || call.Applied || string(got) != string(want) { t.Fatal(call, err) } diff --git a/services/core/internal/store/function_results.go b/services/core/internal/store/function_results.go index 583e6ecf2..911650ac5 100644 --- a/services/core/internal/store/function_results.go +++ b/services/core/internal/store/function_results.go @@ -4,8 +4,10 @@ import ( "context" "encoding/json" "errors" + "fmt" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/db/sqlc" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/jsonobject" "github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5/pgtype" ) @@ -19,9 +21,9 @@ func (s *Store) SubmitFunctionResult(ctx context.Context, tenantID, sessionID, t if len(result) == 0 || len(result) > 512*1024 { return ErrInvalidInput } - result, err := canonicalJSONObject(result) + result, err := jsonobject.Normalize(result) if err != nil { - return err + return fmt.Errorf("%w: %w", ErrInvalidInput, err) } return s.withFunctionCall(ctx, tenantID, sessionID, turnID, callID, func(ctx context.Context, q *sqlc.Queries, turn sqlc.Turn, call sqlc.FunctionCall) error { return storeFunctionResult(ctx, q, turn, call.CallID, result) diff --git a/services/core/internal/store/hosted_initialization_failure_public_test.go b/services/core/internal/store/hosted_initialization_failure_public_test.go index bad7d2821..2a334967a 100644 --- a/services/core/internal/store/hosted_initialization_failure_public_test.go +++ b/services/core/internal/store/hosted_initialization_failure_public_test.go @@ -19,6 +19,7 @@ import ( v1 "github.com/MiniMax-AI/OpenAgentCore/contracts/agents-api/v1" "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/credentialcrypto" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/environmentconfig" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/runtimedevice" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/runtimegateway" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/sandbox" @@ -34,7 +35,7 @@ func failedInitialization(exitCode int) proto.RuntimePrepareResultPayload { return proto.RuntimePrepareResultPayload{Outcome: "failed", ErrorCode: "runtime_preparation_failed", ExitCode: exitCode} } -func hostedFailureSkill(t *testing.T) store.EnvironmentSkill { +func hostedFailureSkill(t *testing.T) environmentconfig.Skill { t.Helper() var archive bytes.Buffer writer := zip.NewWriter(&archive) @@ -48,7 +49,7 @@ func hostedFailureSkill(t *testing.T) store.EnvironmentSkill { if err := writer.Close(); err != nil { t.Fatal(err) } - return store.EnvironmentSkill{Metadata: store.EnvironmentSkillMetadata{Type: "inline", Name: "proof", Description: "A proof."}, Archive: archive.Bytes()} + return environmentconfig.Skill{Metadata: environmentconfig.SkillMetadata{Type: "inline", Name: "proof", Description: "A proof."}, Archive: archive.Bytes()} } // hostedFailureProvider fails one initialization step with a controlled result. @@ -139,7 +140,7 @@ func failHostedInitialization(t *testing.T, s *store.Store, tenant string, envir // with the safe reason and agent.session.failed; reads and events agree, and a // confirmed step names only its label and exit status. func TestHostedInitializationFailureRecordsSafeSessionFailure(t *testing.T) { - commands := []store.SetupCommand{{Command: "echo " + hostedFailureCanary + "; exit 0"}, {Command: "echo " + hostedFailureCanary + "; exit 3"}, {Command: "touch never"}} + commands := []environmentconfig.SetupCommand{{Command: "echo " + hostedFailureCanary + "; exit 0"}, {Command: "echo " + hostedFailureCanary + "; exit 3"}, {Command: "touch never"}} type failure struct { fail string skip int @@ -153,28 +154,28 @@ func TestHostedInitializationFailureRecordsSafeSessionFailure(t *testing.T) { reason string steps []string }{ - {"setup exit status", store.CreateSessionInput{Initialization: store.EnvironmentSetup{Commands: commands[1:]}}, + {"setup exit status", store.CreateSessionInput{Initialization: environmentconfig.Setup{Commands: commands[1:]}}, failure{fail: "setup", result: failedInitialization(3)}, `Failed to provision environment: script "setup_commands[0]" failed with exit code 3`, []string{"configure", "setup"}}, - {"later setup command", store.CreateSessionInput{Initialization: store.EnvironmentSetup{Commands: commands}}, + {"later setup command", store.CreateSessionInput{Initialization: environmentconfig.Setup{Commands: commands}}, failure{fail: "setup", skip: 1, result: failedInitialization(3)}, `Failed to provision environment: script "setup_commands[1]" failed with exit code 3`, []string{"configure", "setup", "setup"}}, - {"python package", store.CreateSessionInput{Initialization: store.EnvironmentSetup{Packages: v1.EnvironmentPackages{Python: []string{"oac-nonexistent-zz"}}, Commands: commands[2:]}}, + {"python package", store.CreateSessionInput{Initialization: environmentconfig.Setup{Packages: v1.EnvironmentPackages{Python: []string{"oac-nonexistent-zz"}}, Commands: commands[2:]}}, failure{fail: "python", result: failedInitialization(1)}, `Failed to provision environment: script "Python package installation" failed with exit code 1`, []string{"configure", "python"}}, - {"failure without exit status", store.CreateSessionInput{Initialization: store.EnvironmentSetup{Commands: commands[1:]}}, + {"failure without exit status", store.CreateSessionInput{Initialization: environmentconfig.Setup{Commands: commands[1:]}}, failure{fail: "setup", result: failedInitialization(0)}, "Failed to provision environment: initialization did not complete", []string{"configure", "setup"}}, - {"unknown effect", store.CreateSessionInput{Initialization: store.EnvironmentSetup{Commands: commands[1:]}}, + {"unknown effect", store.CreateSessionInput{Initialization: environmentconfig.Setup{Commands: commands[1:]}}, failure{fail: "setup", err: sandbox.ErrCommandUnconfirmed}, "Failed to provision environment: initialization did not complete", []string{"configure", "setup"}}, - {"invalid failure code", store.CreateSessionInput{Initialization: store.EnvironmentSetup{Commands: commands[1:]}}, + {"invalid failure code", store.CreateSessionInput{Initialization: environmentconfig.Setup{Commands: commands[1:]}}, failure{fail: "setup", result: proto.RuntimePrepareResultPayload{Outcome: "failed", ErrorCode: hostedFailureCanary}}, "Failed to provision environment: initialization did not complete", []string{"configure", "setup"}}, - {"initial file", store.CreateSessionInput{InitialFiles: []store.InitialFile{{Type: "inline", Path: "/workspace/a", Data: []byte(hostedFailureCanary)}}}, + {"initial file", store.CreateSessionInput{InitialFiles: []environmentconfig.InitialFile{{Type: "inline", Path: "/workspace/a", Data: []byte(hostedFailureCanary)}}}, failure{fail: "file", result: failedInitialization(0)}, "Failed to provision environment: initial file installation failed", []string{"file"}}, - {"Skill", store.CreateSessionInput{Initialization: store.EnvironmentSetup{Skills: []store.EnvironmentSkill{hostedFailureSkill(t)}, Commands: commands[2:]}}, + {"Skill", store.CreateSessionInput{Initialization: environmentconfig.Setup{Skills: []environmentconfig.Skill{hostedFailureSkill(t)}, Commands: commands[2:]}}, failure{fail: "skill", result: failedInitialization(0)}, "Failed to provision environment: Skill installation failed", []string{"configure", "skill"}}, } { @@ -247,7 +248,7 @@ func TestHostedInitializationFailureSettlesPendingInitialInput(t *testing.T) { s := hostedFailureStore(t) tenant := uuid.NewString() session, environment := hostedFailureSession(t, s, tenant, store.CreateSessionInput{ - Initialization: store.EnvironmentSetup{Commands: []store.SetupCommand{{Command: "exit 3"}}}, + Initialization: environmentconfig.Setup{Commands: []environmentconfig.SetupCommand{{Command: "exit 3"}}}, InitialInputs: []store.Input{{Kind: "message", Payload: json.RawMessage(`{"text":"initial"}`)}}, }) p := &hostedFailureProvider{lifecycleProvider: lifecycleProvider{resources: map[string]sandbox.Info{}}, fail: "setup", @@ -281,7 +282,7 @@ func TestHostedInitializationFailurePublicHTTP(t *testing.T) { s := hostedFailureStore(t) tenant, token, foreign := uuid.NewString(), uuid.NewString(), uuid.NewString() session, environment := hostedFailureSession(t, s, tenant, store.CreateSessionInput{ - Initialization: store.EnvironmentSetup{Commands: []store.SetupCommand{{Command: "echo " + hostedFailureCanary + "; exit 3"}}}, + Initialization: environmentconfig.Setup{Commands: []environmentconfig.SetupCommand{{Command: "echo " + hostedFailureCanary + "; exit 3"}}}, Metadata: map[string]string{"case": "setup-exit3"}, }) key := uuid.NewString() diff --git a/services/core/internal/store/initial_files.go b/services/core/internal/store/initial_files.go index 29f3ceda7..6d4f4e89b 100644 --- a/services/core/internal/store/initial_files.go +++ b/services/core/internal/store/initial_files.go @@ -5,84 +5,20 @@ import ( "encoding/json" "errors" "io" - "path" - "strings" - "unicode/utf8" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/credentialcrypto" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/db/sqlc" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/environmentconfig" "github.com/google/uuid" "github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5/pgtype" ) -const MaxInitialFileBytes = 50 << 20 - -// InitialFile keeps confidential input separate from ordinary Session configuration. -type InitialFile struct { - Type string `json:"type"` - Path string `json:"path"` - FileID string `json:"file_id,omitempty"` - Data []byte `json:"data,omitempty"` -} - -type InitialFileMetadata struct { - ID string `json:"id,omitempty"` - Type string `json:"type"` - Path string `json:"path"` - FileID string `json:"file_id,omitempty"` - SizeBytes *int64 `json:"size_bytes,omitempty"` -} - -func ValidateInitialFiles(files []InitialFile) error { - if len(files) > 50 { - return ErrInvalidInput - } - total := 0 - seen := map[string]bool{} - for _, f := range files { - if !utf8.ValidString(f.Path) || strings.ContainsAny(f.Path, "\\\x00\r\n") || len(f.Path) > 4096 || !strings.HasPrefix(f.Path, "/workspace/") || path.Clean(f.Path) != f.Path || seen[f.Path] { - return ErrInvalidInput - } - seen[f.Path] = true - switch f.Type { - case "inline": - if f.FileID != "" || len(f.Data) > 5<<20 { - return ErrInvalidInput - } - total += len(f.Data) - case "file_id": - if f.FileID == "" || len(f.Data) != 0 { - return ErrInvalidInput - } - default: - return ErrInvalidInput - } +func (s *Store) sealTemplateFiles(tenant, id string, files []environmentconfig.InitialFile) ([]byte, []byte, error) { + if environmentconfig.ValidateInitialFiles(files) != nil { + return nil, nil, ErrInvalidInput } - if total > 10<<20 { - return ErrInvalidInput - } - return nil -} - -func initialFileMetadata(files []InitialFile) []InitialFileMetadata { - result := make([]InitialFileMetadata, 0, len(files)) - for _, f := range files { - m := InitialFileMetadata{Type: f.Type, Path: f.Path, FileID: f.FileID} - if f.Type == "inline" { - size := int64(len(f.Data)) - m.SizeBytes = &size - } - result = append(result, m) - } - return result -} - -func (s *Store) sealTemplateFiles(tenant, id string, files []InitialFile) ([]byte, []byte, error) { - if err := ValidateInitialFiles(files); err != nil { - return nil, nil, err - } - metadata, err := json.Marshal(initialFileMetadata(files)) + metadata, err := json.Marshal(environmentconfig.InitialFilesMetadata(files)) if err != nil { return nil, nil, err } @@ -98,11 +34,11 @@ func (s *Store) sealTemplateFiles(tenant, id string, files []InitialFile) ([]byt } // ResolveEnvironmentTemplate reads one atomic snapshot; public reads need no decryption key. -func (s *Store) ResolveEnvironmentTemplate(ctx context.Context, tenant, id string) (EnvironmentTemplate, []InitialFile, error) { +func (s *Store) ResolveEnvironmentTemplate(ctx context.Context, tenant, id string) (EnvironmentTemplate, []environmentconfig.InitialFile, error) { return s.resolveEnvironmentTemplate(ctx, s.queries, tenant, id) } -func (s *Store) resolveEnvironmentTemplate(ctx context.Context, q *sqlc.Queries, tenant, id string) (EnvironmentTemplate, []InitialFile, error) { +func (s *Store) resolveEnvironmentTemplate(ctx context.Context, q *sqlc.Queries, tenant, id string) (EnvironmentTemplate, []environmentconfig.InitialFile, error) { lookup, err := deviceLookup(tenant, id) if err != nil { return EnvironmentTemplate{}, nil, ErrNotFound @@ -143,8 +79,8 @@ func (s *Store) resolveEnvironmentTemplate(ctx context.Context, q *sqlc.Queries, return value, nil, ErrInvalidInput } } - if err = value.Initialization.Validate(); err != nil { - return value, nil, err + if value.Initialization.Validate() != nil { + return value, nil, ErrInvalidInput } if len(row.FileContents) == 0 { if len(value.Files) > 0 { @@ -156,23 +92,23 @@ func (s *Store) resolveEnvironmentTemplate(ctx context.Context, q *sqlc.Queries, if err != nil { return value, nil, err } - var files []InitialFile - if json.Unmarshal(plain, &files) != nil || ValidateInitialFiles(files) != nil { + var files []environmentconfig.InitialFile + if json.Unmarshal(plain, &files) != nil || environmentconfig.ValidateInitialFiles(files) != nil { return value, nil, ErrInvalidInput } return value, files, nil } -func (s *Store) saveInitialFiles(ctx context.Context, q *sqlc.Queries, tx pgx.Tx, tenant string, session pgtype.UUID, files []InitialFile) ([]byte, error) { - if err := ValidateInitialFiles(files); err != nil { - return nil, err +func (s *Store) saveInitialFiles(ctx context.Context, q *sqlc.Queries, tx pgx.Tx, tenant string, session pgtype.UUID, files []environmentconfig.InitialFile) ([]byte, error) { + if environmentconfig.ValidateInitialFiles(files) != nil { + return nil, ErrInvalidInput } tenantID, err := parseID(tenant) if err != nil { return nil, err } tenant = uuid.UUID(tenantID.Bytes).String() - metadata := initialFileMetadata(files) + metadata := environmentconfig.InitialFilesMetadata(files) for i, f := range files { body := f.Data if f.Type == "file_id" { @@ -188,12 +124,12 @@ func (s *Store) saveInitialFiles(ctx context.Context, q *sqlc.Queries, tx pgx.Tx return nil, err } err = consumeSourceFile(ctx, tx, source, func(source SourceFile, reader io.Reader) error { - if source.SizeBytes > MaxInitialFileBytes { + if source.SizeBytes > environmentconfig.MaxInitialFileBytes { return ErrInvalidInput } var err error - body, err = io.ReadAll(io.LimitReader(reader, MaxInitialFileBytes+1)) - if err == nil && (len(body) > MaxInitialFileBytes || int64(len(body)) != source.SizeBytes) { + body, err = io.ReadAll(io.LimitReader(reader, environmentconfig.MaxInitialFileBytes+1)) + if err == nil && (len(body) > environmentconfig.MaxInitialFileBytes || int64(len(body)) != source.SizeBytes) { return ErrInvalidInput } return err @@ -219,19 +155,19 @@ func (s *Store) saveInitialFiles(ctx context.Context, q *sqlc.Queries, tx pgx.Tx } // ReadInitialEnvironmentFile decrypts only the next frozen file, bounding memory per installation. -func (s *Store) ReadInitialEnvironmentFile(ctx context.Context, tenant, session string, position int) (InitialFileMetadata, []byte, error) { +func (s *Store) ReadInitialEnvironmentFile(ctx context.Context, tenant, session string, position int) (environmentconfig.InitialFileMetadata, []byte, error) { lookup, err := deviceLookup(tenant, session) if err != nil { - return InitialFileMetadata{}, nil, err + return environmentconfig.InitialFileMetadata{}, nil, err } row, err := s.queries.GetInitialEnvironmentFile(ctx, sqlc.GetInitialEnvironmentFileParams{TenantID: lookup.TenantID, SessionID: lookup.ID, Position: int32(position)}) if err != nil { - return InitialFileMetadata{}, nil, err + return environmentconfig.InitialFileMetadata{}, nil, err } id := uuid.UUID(row.ID.Bytes).String() body, err := s.credentialCipher.OpenEnvironmentFile(row.Contents, credentialcrypto.EnvironmentFileBinding{TenantID: uuid.UUID(lookup.TenantID.Bytes).String(), Resource: "session", OwnerID: uuid.UUID(lookup.ID.Bytes).String(), FileID: id}) if err == nil && int64(len(body)) != row.SizeBytes { err = ErrInvalidInput } - return InitialFileMetadata{ID: id, Path: row.Path, SizeBytes: &row.SizeBytes}, body, err + return environmentconfig.InitialFileMetadata{ID: id, Path: row.Path, SizeBytes: &row.SizeBytes}, body, err } diff --git a/services/core/internal/store/initial_files_test.go b/services/core/internal/store/initial_files_test.go index f7f8bfcc9..fc6a9c6a6 100644 --- a/services/core/internal/store/initial_files_test.go +++ b/services/core/internal/store/initial_files_test.go @@ -9,6 +9,7 @@ import ( "testing" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/credentialcrypto" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/environmentconfig" "github.com/google/uuid" ) @@ -28,7 +29,7 @@ func TestInitialFilesFrozenEncryptedIsolatedAndRetryable(t *testing.T) { if err != nil { t.Fatal(err) } - files := []InitialFile{{Type: "inline", Path: "/workspace/a/data", Data: canary}, {Type: "file_id", Path: "/workspace/b", FileID: upload.ID}} + files := []environmentconfig.InitialFile{{Type: "inline", Path: "/workspace/a/data", Data: canary}, {Type: "file_id", Path: "/workspace/b", FileID: upload.ID}} template, err := s.CreateEnvironmentTemplate(t.Context(), tenant, EnvironmentTemplateInput{SetFiles: true, Files: files}) if err != nil { t.Fatal(err) @@ -85,7 +86,7 @@ func TestInitialFilesFrozenEncryptedIsolatedAndRetryable(t *testing.T) { } } changed := input - changed.InitialFiles = append([]InitialFile(nil), files...) + changed.InitialFiles = append([]environmentconfig.InitialFile(nil), files...) changed.InitialFiles[0].Data = []byte("changed") if _, err := s.CreateSession(t.Context(), tenant, changed); !errors.Is(err, ErrIdempotencyConflict) { t.Fatal("changed bytes retried", err) diff --git a/services/core/internal/store/json_object.go b/services/core/internal/store/json_object.go deleted file mode 100644 index 480186e2e..000000000 --- a/services/core/internal/store/json_object.go +++ /dev/null @@ -1,28 +0,0 @@ -package store - -import ( - "bytes" - "encoding/json" - "fmt" - "io" -) - -func canonicalJSONObject(raw json.RawMessage) (json.RawMessage, error) { - if len(raw) == 0 { - return json.RawMessage(`{}`), nil - } - decoder := json.NewDecoder(bytes.NewReader(raw)) - decoder.UseNumber() - var fields map[string]any - if err := decoder.Decode(&fields); err != nil || fields == nil { - return nil, fmt.Errorf("%w: value must be a JSON object", ErrInvalidInput) - } - if err := decoder.Decode(new(any)); err != io.EOF { - return nil, fmt.Errorf("%w: value must contain exactly one object", ErrInvalidInput) - } - canonical, err := json.Marshal(fields) - if err != nil { - return nil, fmt.Errorf("%w: invalid JSON object", ErrInvalidInput) - } - return canonical, nil -} diff --git a/services/core/internal/store/runtime_capabilities_pending_test.go b/services/core/internal/store/runtime_capabilities_pending_test.go index 913562f4d..5c687ed41 100644 --- a/services/core/internal/store/runtime_capabilities_pending_test.go +++ b/services/core/internal/store/runtime_capabilities_pending_test.go @@ -7,6 +7,7 @@ import ( "testing" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/credentialcrypto" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/environmentconfig" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/sandbox" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/store" "github.com/google/uuid" @@ -23,7 +24,7 @@ func TestManagedCapabilitiesWaitBeforeInitializationClaim(t *testing.T) { session, err := s.CreateSession(t.Context(), tenant, store.CreateSessionInput{ Creator: store.FixtureCreator(), Engine: "codex", IdempotencyKey: uuid.NewString(), Configuration: json.RawMessage(`{"environment":{"type":"openai_hosted"}}`), - Initialization: store.EnvironmentSetup{CapabilityDirectories: []string{"/workspace/generated"}}, + Initialization: environmentconfig.Setup{CapabilityDirectories: []string{"/workspace/generated"}}, }) if err != nil { t.Fatal(err) diff --git a/services/core/internal/store/runtime_initialization_test.go b/services/core/internal/store/runtime_initialization_test.go index 5875a4f4d..bd35b2c24 100644 --- a/services/core/internal/store/runtime_initialization_test.go +++ b/services/core/internal/store/runtime_initialization_test.go @@ -16,6 +16,7 @@ import ( v1 "github.com/MiniMax-AI/OpenAgentCore/contracts/agents-api/v1" "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/credentialcrypto" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/environmentconfig" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/sandbox" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/store" "github.com/google/uuid" @@ -50,10 +51,10 @@ func TestEnvironmentInitializationCompletionUnknownAndRestart(t *testing.T) { } s := store.NewWithCredentialCipher(pool, cipher) tenant := uuid.NewString() - input := store.CreateSessionInput{Creator: store.FixtureCreator(), Engine: "codex", IdempotencyKey: uuid.NewString(), Configuration: json.RawMessage(`{"environment":{"type":"openai_hosted"}}`), InitialFiles: []store.InitialFile{{Type: "inline", Path: "/workspace/a", Data: []byte("first")}, {Type: "inline", Path: "/workspace/b", Data: []byte("second")}}} + input := store.CreateSessionInput{Creator: store.FixtureCreator(), Engine: "codex", IdempotencyKey: uuid.NewString(), Configuration: json.RawMessage(`{"environment":{"type":"openai_hosted"}}`), InitialFiles: []environmentconfig.InitialFile{{Type: "inline", Path: "/workspace/a", Data: []byte("first")}, {Type: "inline", Path: "/workspace/b", Data: []byte("second")}}} if setupOnly { input.InitialFiles = nil - input.Initialization = store.EnvironmentSetup{Env: map[string]string{"VALUE": "private"}, Packages: v1.EnvironmentPackages{NPM: []string{"is-number@7.0.0"}}, Commands: []store.SetupCommand{{Command: "touch first"}, {Command: "test -f first"}}} + input.Initialization = environmentconfig.Setup{Env: map[string]string{"VALUE": "private"}, Packages: v1.EnvironmentPackages{NPM: []string{"is-number@7.0.0"}}, Commands: []environmentconfig.SetupCommand{{Command: "touch first"}, {Command: "test -f first"}}} expectedSteps = 4 } session, err := s.CreateSession(t.Context(), tenant, input) @@ -156,8 +157,8 @@ func TestManagedRuntimePreparationAllOperationsUsePeer(t *testing.T) { tenant := uuid.NewString() fileBody := bytes.Repeat([]byte("bounded bytes"), 12000) session, environment := hostedFailureSession(t, s, tenant, store.CreateSessionInput{ - InitialFiles: []store.InitialFile{{Type: "inline", Path: "/workspace/first", Data: fileBody}}, - Initialization: store.EnvironmentSetup{Skills: []store.EnvironmentSkill{hostedFailureSkill(t)}, Plugins: []store.EnvironmentPlugin{{Metadata: agentplugin.Metadata{Type: "inline", Name: "plugin", Description: "A plugin."}, Archive: archive.Bytes()}}, Packages: v1.EnvironmentPackages{NPM: []string{"is-number@7.0.0"}, Python: []string{"packaging==24.2"}}, Commands: []store.SetupCommand{{Command: "read installed bundles and create directory"}}, CapabilityDirectories: []string{"/workspace/generated"}}, + InitialFiles: []environmentconfig.InitialFile{{Type: "inline", Path: "/workspace/first", Data: fileBody}}, + Initialization: environmentconfig.Setup{Skills: []environmentconfig.Skill{hostedFailureSkill(t)}, Plugins: []environmentconfig.Plugin{{Metadata: agentplugin.Metadata{Type: "inline", Name: "plugin", Description: "A plugin."}, Archive: archive.Bytes()}}, Packages: v1.EnvironmentPackages{NPM: []string{"is-number@7.0.0"}, Python: []string{"packaging==24.2"}}, Commands: []environmentconfig.SetupCommand{{Command: "read installed bundles and create directory"}}, CapabilityDirectories: []string{"/workspace/generated"}}, }) provider := &initializingProvider{lifecycleProvider: lifecycleProvider{resources: map[string]sandbox.Info{}}} var actions []string diff --git a/services/core/internal/store/runtime_node_lifecycle_fixture_test.go b/services/core/internal/store/runtime_node_lifecycle_fixture_test.go index 94352bc34..9dbbdba5d 100644 --- a/services/core/internal/store/runtime_node_lifecycle_fixture_test.go +++ b/services/core/internal/store/runtime_node_lifecycle_fixture_test.go @@ -15,6 +15,7 @@ import ( "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/credentialcrypto" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/environmentconfig" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/execution" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/runtimedevice" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/runtimegateway" @@ -173,7 +174,7 @@ func (f *nodeIsolationFixture) session(node string, initialize bool) (string, st tenant := uuid.NewString() input := store.CreateSessionInput{Creator: store.FixtureCreator(), Engine: "codex", IdempotencyKey: uuid.NewString(), Configuration: json.RawMessage("{\"agent\":{\"model\":\"test\"},\"environment\":{\"type\":\"openai_hosted\"}}")} if initialize { - input.InitialFiles = []store.InitialFile{{Type: "inline", Path: "/workspace/seed", Data: []byte("retained")}} + input.InitialFiles = []environmentconfig.InitialFile{{Type: "inline", Path: "/workspace/seed", Data: []byte("retained")}} } // Placement is automatic and generation readiness is current-connection // authority. Do not merely change the legacy provider_ready projection. diff --git a/services/core/internal/store/session_configuration_test.go b/services/core/internal/store/session_configuration_test.go index 5cecd0cc8..9e0f925b1 100644 --- a/services/core/internal/store/session_configuration_test.go +++ b/services/core/internal/store/session_configuration_test.go @@ -9,20 +9,6 @@ import ( "github.com/google/uuid" ) -func TestConfigurationCanonicalization(t *testing.T) { - input := ` {"environment":{"type":"none"},"agent":{"revision":9007199254740993,"model":"example"}} ` - want := `{"agent":{"model":"example","revision":9007199254740993},"environment":{"type":"none"}}` - got, err := canonicalJSONObject([]byte(input)) - if err != nil || string(got) != want { - t.Fatalf("canonical = %s, %v; want %s", got, err, want) - } - for _, raw := range []string{`null`, `[]`, `"text"`, `{} {}`, `{`} { - if _, err := canonicalJSONObject([]byte(raw)); !errors.Is(err, ErrInvalidInput) { - t.Fatalf("invalid JSON object accepted (length %d): %v", len(raw), err) - } - } -} - func TestConfigurationSizeLimitSurvivesJSONBRoundTrip(t *testing.T) { s, _ := testStore(t) ctx := context.Background() diff --git a/services/core/internal/store/session_creation_identity.go b/services/core/internal/store/session_creation_identity.go index 25e6f5229..31efd4330 100644 --- a/services/core/internal/store/session_creation_identity.go +++ b/services/core/internal/store/session_creation_identity.go @@ -13,6 +13,7 @@ import ( v1 "github.com/MiniMax-AI/OpenAgentCore/contracts/agents-api/v1" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/db/sqlc" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/identity" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/jsonobject" "github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5/pgtype" ) @@ -89,9 +90,9 @@ func (s *Store) creationRequestHash(raw json.RawMessage) (pgtype.Text, error) { if err != nil { return pgtype.Text{}, err } - canonical, err := canonicalJSONObject(raw) + canonical, err := jsonobject.Normalize(raw) if err != nil { - return pgtype.Text{}, err + return pgtype.Text{}, fmt.Errorf("%w: %w", ErrInvalidInput, err) } hash := sha256.Sum256(canonical) return pgtype.Text{String: hex.EncodeToString(hash[:]), Valid: true}, nil diff --git a/services/core/internal/store/session_execution_configuration_test.go b/services/core/internal/store/session_execution_configuration_test.go index abd8eb0cd..c6c5cc662 100644 --- a/services/core/internal/store/session_execution_configuration_test.go +++ b/services/core/internal/store/session_execution_configuration_test.go @@ -11,6 +11,7 @@ import ( v1 "github.com/MiniMax-AI/OpenAgentCore/contracts/agents-api/v1" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/credentialcrypto" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/environmentconfig" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/runtimedevice" "github.com/google/uuid" ) @@ -182,7 +183,7 @@ func TestSessionExecutionConfigurationRollbackAndValidation(t *testing.T) { input.ExecutionConfiguration.ModelProvider = v1.ExecutionProviderSelection{Source: "session", Status: "available", Configuration: input.ModelProvider.SafeView()} input.ExecutionConfiguration.ModelProvider.Configuration.BaseURL = "https://different.example/v1" case "post_projection_failure": - input.InitialFiles = []InitialFile{{Type: "inline", Path: "invalid-path"}} + input.InitialFiles = []environmentconfig.InitialFile{{Type: "inline", Path: "invalid-path"}} } if _, err := s.CreateSession(t.Context(), tenant, input); !errors.Is(err, ErrInvalidInput) { t.Fatalf("%s: invalid projection/creation accepted: %v", kind, err) diff --git a/services/core/internal/store/session_initial_input.go b/services/core/internal/store/session_initial_input.go index 95e1a9d9f..e79a8c2ca 100644 --- a/services/core/internal/store/session_initial_input.go +++ b/services/core/internal/store/session_initial_input.go @@ -10,6 +10,7 @@ import ( "github.com/jackc/pgx/v5/pgtype" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/db/sqlc" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/environmentconfig" ) func validateInitialInputs(inputs []Input) ([]Input, json.RawMessage, error) { @@ -23,7 +24,7 @@ func validateInitialInputs(inputs []Input) ([]Input, json.RawMessage, error) { // The Session upsert locks retries. Only the new row reserves or admits work, so a // retry after completion or later Turns cannot submit the original input again. -func (s *Store) createSessionResources(ctx context.Context, tenant string, params sqlc.CreateSessionParams, inputs []Input, encodedInput json.RawMessage, files []InitialFile, setup EnvironmentSetup, provider *v1.ModelProviderInput, executionConfiguration *v1.SessionExecutionConfiguration, providerSource string, deploymentRevision uuid.UUID) (sqlc.Session, *Environment, error) { +func (s *Store) createSessionResources(ctx context.Context, tenant string, params sqlc.CreateSessionParams, inputs []Input, encodedInput json.RawMessage, files []environmentconfig.InitialFile, setup environmentconfig.Setup, provider *v1.ModelProviderInput, executionConfiguration *v1.SessionExecutionConfiguration, providerSource string, deploymentRevision uuid.UUID) (sqlc.Session, *Environment, error) { var row sqlc.Session var environment *Environment err := s.pooled.Transaction(ctx, func(ctx context.Context, tx pgx.Tx) error { diff --git a/services/core/internal/store/session_metadata.go b/services/core/internal/store/session_metadata.go index 12ac7d0f4..94fa01d5b 100644 --- a/services/core/internal/store/session_metadata.go +++ b/services/core/internal/store/session_metadata.go @@ -2,24 +2,24 @@ package store import ( "context" - "encoding/json" "errors" "fmt" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/db/sqlc" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/metadata" "github.com/google/uuid" "github.com/jackc/pgx/v5" ) -func (s *Store) UpdateSessionMetadata(ctx context.Context, tenantID, sessionID string, metadata map[string]string) (Session, error) { +func (s *Store) UpdateSessionMetadata(ctx context.Context, tenantID, sessionID string, values map[string]string) (Session, error) { tenant, err := parseID(tenantID) if err != nil { return Session{}, err } id := parsePathID(sessionID) - encoded, err := encodeMetadata(metadata) + encoded, err := metadata.Encode(values) if err != nil { - return Session{}, err + return Session{}, fmt.Errorf("%w: %w", ErrInvalidInput, err) } var row sqlc.Session err = s.pooled.Transaction(ctx, func(ctx context.Context, tx pgx.Tx) error { @@ -40,17 +40,3 @@ func (s *Store) UpdateSessionMetadata(ctx context.Context, tenantID, sessionID s session, decodeErr := sessionFromRow(row) return s.sessionActivity(ctx, session, decodeErr) } - -func encodeMetadata(metadata map[string]string) ([]byte, error) { - if metadata == nil { - metadata = map[string]string{} - } - encoded, err := json.Marshal(metadata) - if err != nil { - return nil, fmt.Errorf("%w: metadata: %v", ErrInvalidInput, err) - } - if len(encoded) > 64*1024 { - return nil, fmt.Errorf("%w: metadata exceeds 64 KiB", ErrInvalidInput) - } - return encoded, nil -} diff --git a/services/core/internal/store/sessions.go b/services/core/internal/store/sessions.go index 19f885327..a4a2e86b2 100644 --- a/services/core/internal/store/sessions.go +++ b/services/core/internal/store/sessions.go @@ -20,7 +20,10 @@ import ( v1 "github.com/MiniMax-AI/OpenAgentCore/contracts/agents-api/v1" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/credentialcrypto" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/db/sqlc" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/environmentconfig" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/identity" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/jsonobject" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/metadata" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/oauthrefresh" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/persistence/postgres/pgunit" ) @@ -63,8 +66,8 @@ type CreateSessionInput struct { ExecutionConfiguration *v1.SessionExecutionConfiguration ModelProvider *v1.ModelProviderInput ModelProviderSource string // session, agent or deployment; empty allows only openai_hosted - Initialization EnvironmentSetup - InitialFiles []InitialFile + Initialization environmentconfig.Setup + InitialFiles []environmentconfig.InitialFile Creator identity.Subject CreationRequest json.RawMessage Engine string @@ -126,16 +129,16 @@ func (s *Store) createSession(ctx context.Context, tenantID string, input Create if input.Metadata == nil { input.Metadata = map[string]string{} } - metadata, err := encodeMetadata(input.Metadata) + encodedMetadata, err := metadata.Encode(input.Metadata) if err != nil { - return SessionCreation{}, err + return SessionCreation{}, fmt.Errorf("%w: %w", ErrInvalidInput, err) } if len(input.Configuration) > 512*1024 { return SessionCreation{}, fmt.Errorf("%w: configuration exceeds 512 KiB", ErrInvalidInput) } - configuration, err := canonicalJSONObject(input.Configuration) + configuration, err := jsonobject.Normalize(input.Configuration) if err != nil { - return SessionCreation{}, err + return SessionCreation{}, fmt.Errorf("%w: %w", ErrInvalidInput, err) } var batch []Input var encodedInput json.RawMessage @@ -167,7 +170,7 @@ func (s *Store) createSession(ctx context.Context, tenantID string, input Create return SessionCreation{}, err } } - var initialization *EnvironmentSetup + var initialization *environmentconfig.Setup if !input.Initialization.Empty() { initialization = &input.Initialization } @@ -187,10 +190,10 @@ func (s *Store) createSession(ctx context.Context, tenantID string, input Create ModelProvider *v1.ModelProviderInput `json:",omitempty"` Engine string Metadata map[string]string - Configuration json.RawMessage `json:",omitempty"` - InitialInputs json.RawMessage `json:",omitempty"` - InitialFiles []InitialFile `json:",omitempty"` - Initialization *EnvironmentSetup `json:",omitempty"` + Configuration json.RawMessage `json:",omitempty"` + InitialInputs json.RawMessage `json:",omitempty"` + InitialFiles []environmentconfig.InitialFile `json:",omitempty"` + Initialization *environmentconfig.Setup `json:",omitempty"` }{fingerprinted, input.Engine, input.Metadata, hashed, encodedInput, input.InitialFiles, initialization}) if err != nil { return SessionCreation{}, fmt.Errorf("%w: input: %v", ErrInvalidInput, err) @@ -202,7 +205,7 @@ func (s *Store) createSession(ctx context.Context, tenantID string, input Create hash := sha256.Sum256(canonical) params := sqlc.CreateSessionParams{ ID: pgtype.UUID{Bytes: uuid.New(), Valid: true}, TenantID: tenant, Engine: input.Engine, - Metadata: metadata, IdempotencyKey: input.IdempotencyKey, RequestHash: hex.EncodeToString(hash[:]), + Metadata: encodedMetadata, IdempotencyKey: input.IdempotencyKey, RequestHash: hex.EncodeToString(hash[:]), Configuration: configuration, CreationRequestHash: creationHash, CreatorKind: pgtype.Text{String: input.Creator.Kind, Valid: true}, CreatorID: pgtype.Text{String: input.Creator.ID, Valid: true}, } @@ -309,9 +312,9 @@ func sessionFromRow(row sqlc.Session) (Session, error) { return Session{}, err } session.Creator = creator - configuration, err := canonicalJSONObject(row.Configuration) + configuration, err := jsonobject.Normalize(row.Configuration) if err != nil { - return Session{}, fmt.Errorf("decode session configuration: %w", err) + return Session{}, fmt.Errorf("decode session configuration: %w: %w", ErrInvalidInput, err) } session.Configuration = configuration if err := json.Unmarshal(row.Metadata, &session.Metadata); err != nil { diff --git a/services/core/internal/store/skill_version_deletion_test.go b/services/core/internal/store/skill_version_deletion_test.go index b4f169d6f..ea0597694 100644 --- a/services/core/internal/store/skill_version_deletion_test.go +++ b/services/core/internal/store/skill_version_deletion_test.go @@ -9,6 +9,7 @@ import ( "time" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/credentialcrypto" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/environmentconfig" "github.com/google/uuid" "github.com/jackc/pgx/v5/pgxpool" ) @@ -45,7 +46,7 @@ func TestSoleSkillVersionDeletionRemovesSkill(t *testing.T) { if err != nil { t.Fatal(err) } - reference := EnvironmentSetup{Skills: []EnvironmentSkill{{Metadata: EnvironmentSkillMetadata{Type: "skill_reference", SkillID: skill.ID}}}} + reference := environmentconfig.Setup{Skills: []environmentconfig.Skill{{Metadata: environmentconfig.SkillMetadata{Type: "skill_reference", SkillID: skill.ID}}}} template, err := s.CreateEnvironmentTemplate(t.Context(), tenant, EnvironmentTemplateInput{SetSkills: true, Initialization: reference}) if err != nil { t.Fatal(err) diff --git a/services/core/internal/store/skills.go b/services/core/internal/store/skills.go index d10d46a27..cc33d9a8d 100644 --- a/services/core/internal/store/skills.go +++ b/services/core/internal/store/skills.go @@ -10,6 +10,7 @@ import ( "github.com/MiniMax-AI/OpenAgentCore/internal/agentskill" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/credentialcrypto" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/db/sqlc" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/skills" "github.com/google/uuid" "github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5/pgtype" @@ -79,9 +80,9 @@ func (s *Store) UpdateSkillDefault(ctx context.Context, tenantID, skillID, versi if err != nil { return Skill{}, err } - number, err := skillVersionNumber(version) + number, err := skills.ParseVersion(version) if err != nil { - return Skill{}, err + return Skill{}, ErrInvalidInput } var result Skill err = s.pooled.Transaction(ctx, func(ctx context.Context, tx pgx.Tx) error { @@ -155,14 +156,6 @@ func skillResourceID(value, prefix string) (pgtype.UUID, error) { return pgtype.UUID{Bytes: id, Valid: true}, nil } -func skillVersionNumber(value string) (int64, error) { - number, err := strconv.ParseInt(value, 10, 64) - if err != nil || number < 1 || strconv.FormatInt(number, 10) != value { - return 0, ErrInvalidInput - } - return number, nil -} - // skillPathIDs resolves a Skill path identifier. Like parsePathID, a malformed // value resolves to an identifier that never exists, so the request follows the // missing-Skill path. Request-body references keep skillIDs. @@ -176,9 +169,9 @@ func skillPathIDs(tenantID, skillID string) (pgtype.UUID, pgtype.UUID, error) { // skillPathVersion resolves a version path segment. Versions start at 1, so a // malformed segment resolves to the never-assigned version 0 and follows the -// missing-version path. Request-body selectors keep skillVersionNumber. +// missing-version path. Request-body selectors keep skills.ParseVersion. func skillPathVersion(value string) int64 { - number, err := skillVersionNumber(value) + number, err := skills.ParseVersion(value) if err != nil { return 0 } diff --git a/services/core/internal/store/template_composition_public_test.go b/services/core/internal/store/template_composition_public_test.go index 1d57d2e86..a27d8869e 100644 --- a/services/core/internal/store/template_composition_public_test.go +++ b/services/core/internal/store/template_composition_public_test.go @@ -14,6 +14,7 @@ import ( v1 "github.com/MiniMax-AI/OpenAgentCore/contracts/agents-api/v1" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/credentialcrypto" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/environmentconfig" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/runtimedevice" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/store" "github.com/google/uuid" @@ -81,14 +82,14 @@ func TestTemplateCompositionOfficialClientPostgres(t *testing.T) { // A second handler/Store exercises reopened persistence, not an OS process restart. for label, id := range receipt.Sessions { env := map[string]string{"TEMPLATE_ONLY": marker + "-template-env", "SHARED": marker + "-template-shared"} - commands := []store.SetupCommand{{Command: "printf " + marker + "-template-command", CWD: "/workspace"}} + commands := []environmentconfig.SetupCommand{{Command: "printf " + marker + "-template-command", CWD: "/workspace"}} packages := v1.EnvironmentPackages{Python: []string{"packaging==25.0"}, NPM: []string{"semver@7.7.2"}} paths := []string{"/workspace/template-only.txt", "/workspace/overlap.txt"} contents := []string{marker + "-template-file", marker + "-source"} switch label { case "populated": env["SHARED"], env["INLINE_ONLY"] = marker+"-inline-shared", marker+"-inline-env" - commands = []store.SetupCommand{{Command: "printf " + marker + "-inline-one"}, {Command: "printf " + marker + "-inline-two", CWD: "/workspace"}} + commands = []environmentconfig.SetupCommand{{Command: "printf " + marker + "-inline-one"}, {Command: "printf " + marker + "-inline-two", CWD: "/workspace"}} packages.Python, packages.NPM = []string{"idna==3.10"}, []string{} paths = []string{"/workspace/overlap.txt", "/workspace/selected-source.txt"} contents = []string{marker + "-inline-file", marker + "-source"} diff --git a/services/core/internal/store/template_null_selection_public_test.go b/services/core/internal/store/template_null_selection_public_test.go index f53c7f928..48d31b759 100644 --- a/services/core/internal/store/template_null_selection_public_test.go +++ b/services/core/internal/store/template_null_selection_public_test.go @@ -16,6 +16,7 @@ import ( "github.com/MiniMax-AI/OpenAgentCore/internal/agentplugin" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/credentialcrypto" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/environmentconfig" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/runtimedevice" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/store" "github.com/google/uuid" @@ -66,11 +67,11 @@ func TestTemplateNullSelectionOfficialClientPostgres(t *testing.T) { Sessions map[string]string `json:"sessions"` RejectedKeys []string `json:"rejected_keys"` Expected map[string]struct { - Skills []store.EnvironmentSkillMetadata `json:"skills"` - Plugins []agentplugin.Metadata `json:"plugins"` - CapabilityDirectories []string `json:"capability_directories"` - SkillDigests []string `json:"skill_digests"` - PluginDigests []string `json:"plugin_digests"` + Skills []environmentconfig.SkillMetadata `json:"skills"` + Plugins []agentplugin.Metadata `json:"plugins"` + CapabilityDirectories []string `json:"capability_directories"` + SkillDigests []string `json:"skill_digests"` + PluginDigests []string `json:"plugin_digests"` } `json:"expected"` } if err := json.Unmarshal(output, &receipt); err != nil { diff --git a/services/core/internal/store/turn_completion.go b/services/core/internal/store/turn_completion.go index 099264eab..3c188f0ce 100644 --- a/services/core/internal/store/turn_completion.go +++ b/services/core/internal/store/turn_completion.go @@ -4,9 +4,11 @@ import ( "context" "encoding/json" "errors" + "fmt" "time" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/db/sqlc" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/jsonobject" "github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5/pgtype" ) @@ -22,9 +24,9 @@ func (s *Store) CompleteExecution(ctx context.Context, tenantID, sessionID, turn if !terminalStatus(status) || len(outcome) > 512*1024 || len(nativeID) > 512 || appliedThrough < 0 { return Turn{}, ErrInvalidInput } - outcome, err = canonicalJSONObject(outcome) + outcome, err = jsonobject.Normalize(outcome) if err != nil { - return Turn{}, err + return Turn{}, fmt.Errorf("%w: %w", ErrInvalidInput, err) } var row sqlc.Turn err = s.withSession(ctx, tenantID, sessionID, func(ctx context.Context, q *sqlc.Queries, session pgtype.UUID) error { diff --git a/services/core/internal/store/turn_events.go b/services/core/internal/store/turn_events.go index d419a26ed..ec262729d 100644 --- a/services/core/internal/store/turn_events.go +++ b/services/core/internal/store/turn_events.go @@ -4,10 +4,12 @@ import ( "context" "encoding/json" "errors" + "fmt" "slices" "time" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/db/sqlc" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/jsonobject" "github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5/pgtype" ) @@ -48,9 +50,9 @@ func (s *Store) AppendTurnEvents(ctx context.Context, tenantID, sessionID, turnI if len(event.Payload) > 512*1024 || !enginePattern.MatchString(event.Kind) { return ErrInvalidInput } - payload, err := canonicalJSONObject(event.Payload) + payload, err := jsonobject.Normalize(event.Payload) if err != nil { - return err + return fmt.Errorf("%w: %w", ErrInvalidInput, err) } normalized[i] = ExecutionEvent{Kind: event.Kind, Payload: payload} payloadBytes += len(payload) diff --git a/services/core/internal/store/turn_inputs.go b/services/core/internal/store/turn_inputs.go index 509978809..9a794d9b2 100644 --- a/services/core/internal/store/turn_inputs.go +++ b/services/core/internal/store/turn_inputs.go @@ -14,6 +14,7 @@ import ( "github.com/jackc/pgx/v5/pgtype" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/db/sqlc" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/jsonobject" ) type InputReceipt struct { @@ -131,9 +132,9 @@ func validateInputs(inputs []Input) ([]Input, json.RawMessage, error) { if size > 512*1024 || len(input.Payload) == 0 || (input.Kind != "message" && input.Kind != "cancel" && input.Kind != "tool_result") { return nil, nil, fmt.Errorf("%w: input payloads must be nonempty and total at most 512 KiB", ErrInvalidInput) } - payload, err := canonicalJSONObject(input.Payload) + payload, err := jsonobject.Normalize(input.Payload) if err != nil { - return nil, nil, err + return nil, nil, fmt.Errorf("%w: %w", ErrInvalidInput, err) } if input.Kind == "cancel" && string(payload) != "{}" { return nil, nil, fmt.Errorf("%w: cancel payload must be empty", ErrInvalidInput) diff --git a/services/core/internal/store/turns.go b/services/core/internal/store/turns.go index ae5e4e797..6a046a711 100644 --- a/services/core/internal/store/turns.go +++ b/services/core/internal/store/turns.go @@ -12,6 +12,7 @@ import ( "github.com/jackc/pgx/v5/pgtype" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/db/sqlc" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/jsonobject" ) var ErrTurnConflict = errors.New("turn state changed or cancellation was requested") @@ -76,9 +77,9 @@ func (s *Store) TransitionTurn(ctx context.Context, tenantID, sessionID, turnID if !validTransition(input.ExpectedStatus, input.Status) || len(input.Outcome) > 512*1024 { return Turn{}, fmt.Errorf("%w: invalid turn transition or outcome size", ErrInvalidInput) } - outcome, err := canonicalJSONObject(input.Outcome) + outcome, err := jsonobject.Normalize(input.Outcome) if err != nil { - return Turn{}, err + return Turn{}, fmt.Errorf("%w: %w", ErrInvalidInput, err) } if !terminalStatus(input.Status) && string(outcome) != "{}" { return Turn{}, fmt.Errorf("%w: outcome requires a terminal status", ErrInvalidInput) diff --git a/services/core/internal/store/vaults.go b/services/core/internal/store/vaults.go index ead0e9ccb..05eb8ca0a 100644 --- a/services/core/internal/store/vaults.go +++ b/services/core/internal/store/vaults.go @@ -13,6 +13,7 @@ import ( "github.com/jackc/pgx/v5/pgtype" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/db/sqlc" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/metadata" ) // Vault is a tenant-owned resource, independent of Sessions and engine execution. @@ -43,16 +44,16 @@ func (s *Store) CreateVault(ctx context.Context, tenantID string, input CreateVa } name = pgtype.Text{String: *input.Name, Valid: true} } - metadata, err := encodeMetadata(input.Metadata) + encodedMetadata, err := metadata.Encode(input.Metadata) if err != nil { - return Vault{}, err + return Vault{}, fmt.Errorf("%w: %w", ErrInvalidInput, err) } var created Vault err = s.pooled.Transaction(ctx, func(ctx context.Context, tx pgx.Tx) error { q := s.queries.WithTx(tx) row, err := q.CreateVault(ctx, sqlc.CreateVaultParams{ ID: pgtype.UUID{Bytes: uuid.New(), Valid: true}, TenantID: tenant, - Name: name, Metadata: metadata, + Name: name, Metadata: encodedMetadata, }) if err != nil { return err