From ec47ef5696bcab87a6358fe5fde0b546d2d80b6a Mon Sep 17 00:00:00 2001 From: SaladDay <1203511142@qq.com> Date: Wed, 30 Sep 2026 16:14:32 +0000 Subject: [PATCH 1/2] Add the environmenttemplates domain and its templatepg adapter --- .../core/internal/environmenttemplates/doc.go | 6 + .../internal/environmenttemplates/errors.go | 12 + .../internal/environmenttemplates/service.go | 58 +++ .../environmenttemplates/service_test.go | 99 +++++ .../internal/environmenttemplates/storage.go | 21 + .../internal/environmenttemplates/template.go | 113 +++++ .../environmenttemplates/template_test.go | 76 ++++ .../postgres/templatepg/audit_test.go | 174 ++++++++ .../persistence/postgres/templatepg/seal.go | 101 +++++ .../persistence/postgres/templatepg/store.go | 272 ++++++++++++ .../postgres/templatepg/store_test.go | 390 ++++++++++++++++++ 11 files changed, 1322 insertions(+) create mode 100644 services/core/internal/environmenttemplates/doc.go create mode 100644 services/core/internal/environmenttemplates/errors.go create mode 100644 services/core/internal/environmenttemplates/service.go create mode 100644 services/core/internal/environmenttemplates/service_test.go create mode 100644 services/core/internal/environmenttemplates/storage.go create mode 100644 services/core/internal/environmenttemplates/template.go create mode 100644 services/core/internal/environmenttemplates/template_test.go create mode 100644 services/core/internal/persistence/postgres/templatepg/audit_test.go create mode 100644 services/core/internal/persistence/postgres/templatepg/seal.go create mode 100644 services/core/internal/persistence/postgres/templatepg/store.go create mode 100644 services/core/internal/persistence/postgres/templatepg/store_test.go diff --git a/services/core/internal/environmenttemplates/doc.go b/services/core/internal/environmenttemplates/doc.go new file mode 100644 index 00000000..819b104e --- /dev/null +++ b/services/core/internal/environmenttemplates/doc.go @@ -0,0 +1,6 @@ +// Package environmenttemplates owns Environment Templates: saved, tenant-owned +// hosted Environment configuration that Sessions reference at creation. It owns +// the Template vocabulary, the rules for Template input and the create, update +// and delete operations. Reader reads Templates, including the decrypted +// configuration Session creation composes over. +package environmenttemplates diff --git a/services/core/internal/environmenttemplates/errors.go b/services/core/internal/environmenttemplates/errors.go new file mode 100644 index 00000000..1591eaee --- /dev/null +++ b/services/core/internal/environmenttemplates/errors.go @@ -0,0 +1,12 @@ +package environmenttemplates + +import "errors" + +var ( + // ErrNotFound reports a Template, or a list cursor, that names no Template of + // the tenant. Missing, malformed and foreign IDs are indistinguishable. + ErrNotFound = errors.New("environment template not found") + // ErrInvalidInput reports input that is not a valid Template, or a saved + // Template whose configuration no longer decodes as one. + ErrInvalidInput = errors.New("invalid environment template") +) diff --git a/services/core/internal/environmenttemplates/service.go b/services/core/internal/environmenttemplates/service.go new file mode 100644 index 00000000..5ecb52f0 --- /dev/null +++ b/services/core/internal/environmenttemplates/service.go @@ -0,0 +1,58 @@ +package environmenttemplates + +import ( + "context" + "errors" +) + +// Service runs the Template write operations. +type Service struct { + storage Storage +} + +func NewService(storage Storage) (*Service, error) { + if storage == nil { + return nil, errors.New("environmenttemplates: storage is required") + } + return &Service{storage: storage}, nil +} + +type CreateCommand struct { + TenantID string + Input Input +} + +type UpdateCommand struct { + TenantID string + TemplateID string + Input Input +} + +type DeleteCommand struct { + TenantID string + TemplateID string +} + +// Create saves a new Template. Input without a network policy gets the default. +func (s *Service) Create(ctx context.Context, cmd CreateCommand) (Template, error) { + in := cmd.Input.withDefaultNetwork() + if err := in.Validate(); err != nil { + return Template{}, err + } + return s.storage.Create(ctx, cmd.TenantID, in) +} + +// Update replaces the fields the input sets. Invalid input is rejected before +// the Template is looked up. +func (s *Service) Update(ctx context.Context, cmd UpdateCommand) (Template, error) { + if err := cmd.Input.Validate(); err != nil { + return Template{}, err + } + return s.storage.Update(ctx, cmd.TenantID, cmd.TemplateID, cmd.Input) +} + +// Delete deletes a Template and returns its ID. Sessions created from it keep +// their frozen configuration. +func (s *Service) Delete(ctx context.Context, cmd DeleteCommand) (string, error) { + return s.storage.Delete(ctx, cmd.TenantID, cmd.TemplateID) +} diff --git a/services/core/internal/environmenttemplates/service_test.go b/services/core/internal/environmenttemplates/service_test.go new file mode 100644 index 00000000..e54f0150 --- /dev/null +++ b/services/core/internal/environmenttemplates/service_test.go @@ -0,0 +1,99 @@ +package environmenttemplates + +import ( + "context" + "errors" + "testing" +) + +type fakeStorage struct { + t *testing.T + create func(context.Context, string, Input) (Template, error) + update func(context.Context, string, string, Input) (Template, error) + delete func(context.Context, string, string) (string, error) +} + +func (f *fakeStorage) Create(ctx context.Context, tenantID string, in Input) (Template, error) { + if f.create == nil { + f.t.Fatal("unexpected call to Create") + } + return f.create(ctx, tenantID, in) +} + +func (f *fakeStorage) Update(ctx context.Context, tenantID, templateID string, in Input) (Template, error) { + if f.update == nil { + f.t.Fatal("unexpected call to Update") + } + return f.update(ctx, tenantID, templateID, in) +} + +func (f *fakeStorage) Delete(ctx context.Context, tenantID, templateID string) (string, error) { + if f.delete == nil { + f.t.Fatal("unexpected call to Delete") + } + return f.delete(ctx, tenantID, templateID) +} + +func newService(t *testing.T, storage *fakeStorage) *Service { + t.Helper() + storage.t = t + service, err := NewService(storage) + if err != nil { + t.Fatal(err) + } + return service +} + +func TestNewServiceRequiresStorage(t *testing.T) { + if _, err := NewService(nil); err == nil { + t.Fatal("NewService(nil) succeeded") + } +} + +func TestCreateSavesDefaultNetwork(t *testing.T) { + service := newService(t, &fakeStorage{create: func(_ context.Context, tenantID string, in Input) (Template, error) { + if tenantID != "tenant" || !in.SetNetwork || in.NetworkAccess != "enabled" || in.AllowedDomains != nil || *in.Name != "name" { + t.Fatalf("Create(%q, %+v)", tenantID, in) + } + return Template{ID: "template"}, nil + }}) + got, err := service.Create(context.Background(), CreateCommand{TenantID: "tenant", Input: Input{Name: ptr("name")}}) + if err != nil || got.ID != "template" { + t.Fatalf("Create() = %+v, %v", got, err) + } +} + +func TestInvalidWritesNeverReachStorage(t *testing.T) { + service := newService(t, &fakeStorage{}) + invalid := Input{SetNetwork: true, NetworkAccess: "restricted"} + if _, err := service.Create(context.Background(), CreateCommand{TenantID: "tenant", Input: invalid}); !errors.Is(err, ErrInvalidInput) { + t.Fatalf("Create() error = %v", err) + } + if _, err := service.Update(context.Background(), UpdateCommand{TenantID: "tenant", TemplateID: "missing", Input: invalid}); !errors.Is(err, ErrInvalidInput) { + t.Fatalf("Update() error = %v", err) + } +} + +func TestUpdateKeepsUnsetNetwork(t *testing.T) { + service := newService(t, &fakeStorage{update: func(_ context.Context, tenantID, templateID string, in Input) (Template, error) { + if tenantID != "tenant" || templateID != "template" || in.SetNetwork || !in.SetName { + t.Fatalf("Update(%q, %q, %+v)", tenantID, templateID, in) + } + return Template{ID: templateID}, nil + }}) + if _, err := service.Update(context.Background(), UpdateCommand{TenantID: "tenant", TemplateID: "template", Input: Input{SetName: true}}); err != nil { + t.Fatal(err) + } +} + +func TestDeleteReturnsStorageOutcome(t *testing.T) { + service := newService(t, &fakeStorage{delete: func(_ context.Context, tenantID, templateID string) (string, error) { + if tenantID != "tenant" || templateID != "missing" { + t.Fatalf("Delete(%q, %q)", tenantID, templateID) + } + return "", ErrNotFound + }}) + if _, err := service.Delete(context.Background(), DeleteCommand{TenantID: "tenant", TemplateID: "missing"}); !errors.Is(err, ErrNotFound) { + t.Fatalf("Delete() error = %v", err) + } +} diff --git a/services/core/internal/environmenttemplates/storage.go b/services/core/internal/environmenttemplates/storage.go new file mode 100644 index 00000000..d5b4e6ed --- /dev/null +++ b/services/core/internal/environmenttemplates/storage.go @@ -0,0 +1,21 @@ +package environmenttemplates + +import "context" + +// Storage saves Templates. Each method runs in one transaction that also +// records the write audit, so a failed audit leaves nothing changed. Update and +// Delete return ErrNotFound for a Template the tenant does not own. +type Storage interface { + Create(ctx context.Context, tenantID string, in Input) (Template, error) + Update(ctx context.Context, tenantID, templateID string, in Input) (Template, error) + Delete(ctx context.Context, tenantID, templateID string) (string, error) +} + +// Reader reads a tenant's Templates. Get and List return safe metadata without +// the credential key. Resolve decrypts the configuration for Session creation. +// An unknown Template or cursor returns ErrNotFound. +type Reader interface { + Get(ctx context.Context, tenantID, templateID string) (Template, error) + List(ctx context.Context, tenantID string, query ListQuery) (Page, error) + Resolve(ctx context.Context, tenantID, templateID string) (Resolved, error) +} diff --git a/services/core/internal/environmenttemplates/template.go b/services/core/internal/environmenttemplates/template.go new file mode 100644 index 00000000..dfb868cc --- /dev/null +++ b/services/core/internal/environmenttemplates/template.go @@ -0,0 +1,113 @@ +package environmenttemplates + +import ( + "time" + "unicode/utf8" + + v1 "github.com/MiniMax-AI/OpenAgentCore/contracts/agents-api/v1" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentnetwork" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentplugin" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/environmentconfig" +) + +// Template is a saved Template's safe metadata: it holds no confidential +// setup, file contents or archives, so reading it needs no credential key. +type Template struct { + ID string + Name *string + NetworkAccess string + AllowedDomains []string + Packages v1.EnvironmentPackages + Files []environmentconfig.InitialFileMetadata + Skills []environmentconfig.SkillMetadata + Plugins []agentplugin.Metadata + CapabilityDirectories []string + CreatedAt time.Time + UpdatedAt time.Time +} + +// Resolved is a Template with its decrypted configuration, read in one snapshot +// for Session creation. Skill references keep the selectors the Template saved. +type Resolved struct { + Template Template + Setup environmentconfig.Setup + Files []environmentconfig.InitialFile +} + +// Input is Template configuration as a create or update request states it. +// Create saves every field. Update replaces exactly the fields whose Set flag +// is true, each atomically, and keeps the others. +type Input struct { + Name *string + SetName bool + NetworkAccess string + AllowedDomains []string + SetNetwork bool + Files []environmentconfig.InitialFile + SetFiles bool + Setup environmentconfig.Setup + SetEnv bool + SetCommands bool + SetPackages bool + SetSkills bool + SetPlugins bool + SetDirectories bool +} + +// MaxNameLength is the longest Template name, in characters. +const MaxNameLength = 256 + +// ValidateName accepts no name or a valid UTF-8 name of 1 to MaxNameLength +// characters. +func ValidateName(name *string) error { + if name != nil && (!utf8.ValidString(*name) || utf8.RuneCountInString(*name) < 1 || utf8.RuneCountInString(*name) > MaxNameLength) { + return ErrInvalidInput + } + return nil +} + +// Validate reports whether the input is valid Template configuration. The +// network policy is checked only when the input sets one. +func (in Input) Validate() error { + if ValidateName(in.Name) != nil || in.Setup.Validate() != nil || environmentconfig.ValidateInitialFiles(in.Files) != nil { + return ErrInvalidInput + } + if in.SetNetwork && (agentnetwork.Policy{Access: in.NetworkAccess, AllowedDomains: in.AllowedDomains}).Validate() != nil { + return ErrInvalidInput + } + return nil +} + +// withDefaultNetwork gives a new Template without a network policy the pinned +// default: network access enabled. +func (in Input) withDefaultNetwork() Input { + if !in.SetNetwork { + in.NetworkAccess, in.AllowedDomains, in.SetNetwork = "enabled", nil, true + } + return in +} + +// MaxListLimit is the largest page a list returns. +const MaxListLimit = 100 + +// ListQuery selects one page of a tenant's Templates in creation order, with ID +// tie-breaking. After is the previous page's last Template ID, or empty. +type ListQuery struct { + After string + Limit int + Ascending bool +} + +// Validate accepts a Limit from 1 to MaxListLimit. +func (q ListQuery) Validate() error { + if q.Limit < 1 || q.Limit > MaxListLimit { + return ErrInvalidInput + } + return nil +} + +// Page is one page of Templates. +type Page struct { + Templates []Template + HasMore bool +} diff --git a/services/core/internal/environmenttemplates/template_test.go b/services/core/internal/environmenttemplates/template_test.go new file mode 100644 index 00000000..4715ffb5 --- /dev/null +++ b/services/core/internal/environmenttemplates/template_test.go @@ -0,0 +1,76 @@ +package environmenttemplates + +import ( + "errors" + "strings" + "testing" + + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/environmentconfig" +) + +func TestValidateName(t *testing.T) { + for _, tc := range []struct { + name string + value *string + valid bool + }{ + {"absent", nil, true}, + {"one character", ptr("a"), true}, + {"longest", ptr(strings.Repeat("界", MaxNameLength)), true}, + {"empty", ptr(""), false}, + {"too long", ptr(strings.Repeat("a", MaxNameLength+1)), false}, + {"invalid UTF-8", ptr("\xff"), false}, + } { + t.Run(tc.name, func(t *testing.T) { + if err := ValidateName(tc.value); (err == nil) != tc.valid || (err != nil && !errors.Is(err, ErrInvalidInput)) { + t.Fatalf("ValidateName() = %v, want valid %v", err, tc.valid) + } + }) + } +} + +func TestInputValidate(t *testing.T) { + for _, tc := range []struct { + name string + input Input + valid bool + }{ + {"empty", Input{}, true}, + {"network unset ignores policy fields", Input{NetworkAccess: "bogus"}, true}, + {"restricted with domains", Input{SetNetwork: true, NetworkAccess: "restricted", AllowedDomains: []string{"example.com"}}, true}, + {"restricted without domains", Input{SetNetwork: true, NetworkAccess: "restricted"}, false}, + {"unknown access", Input{SetNetwork: true, NetworkAccess: "bogus"}, false}, + {"invalid name", Input{Name: ptr("")}, false}, + {"reserved env", Input{Setup: environmentconfig.Setup{Env: map[string]string{"PATH": "/bin"}}}, false}, + {"empty command", Input{Setup: environmentconfig.Setup{Commands: []environmentconfig.SetupCommand{{}}}}, false}, + {"file outside workspace", Input{Files: []environmentconfig.InitialFile{{Type: "inline", Path: "/etc/passwd"}}}, false}, + {"inline file", Input{Files: []environmentconfig.InitialFile{{Type: "inline", Path: "/workspace/a.txt", Data: []byte("a")}}}, true}, + } { + t.Run(tc.name, func(t *testing.T) { + if err := tc.input.Validate(); (err == nil) != tc.valid || (err != nil && !errors.Is(err, ErrInvalidInput)) { + t.Fatalf("Validate() = %v, want valid %v", err, tc.valid) + } + }) + } +} + +func TestWithDefaultNetwork(t *testing.T) { + got := Input{AllowedDomains: []string{"example.com"}}.withDefaultNetwork() + if !got.SetNetwork || got.NetworkAccess != "enabled" || got.AllowedDomains != nil { + t.Fatalf("default network = %+v", got) + } + explicit := Input{SetNetwork: true, NetworkAccess: "disabled"}.withDefaultNetwork() + if explicit.NetworkAccess != "disabled" { + t.Fatalf("explicit network replaced: %+v", explicit) + } +} + +func TestListQueryValidate(t *testing.T) { + for limit, valid := range map[int]bool{0: false, 1: true, MaxListLimit: true, MaxListLimit + 1: false, -1: false} { + if err := (ListQuery{Limit: limit}).Validate(); (err == nil) != valid || (err != nil && !errors.Is(err, ErrInvalidInput)) { + t.Fatalf("limit %d: Validate() = %v, want valid %v", limit, err, valid) + } + } +} + +func ptr(value string) *string { return &value } diff --git a/services/core/internal/persistence/postgres/templatepg/audit_test.go b/services/core/internal/persistence/postgres/templatepg/audit_test.go new file mode 100644 index 00000000..ac50c78b --- /dev/null +++ b/services/core/internal/persistence/postgres/templatepg/audit_test.go @@ -0,0 +1,174 @@ +package templatepg_test + +import ( + "context" + "errors" + "reflect" + "strings" + "testing" + + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/adminaudit" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/environmentconfig" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/environmenttemplates" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/persistence/postgres/pgtest" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/writeaudit" + "github.com/google/uuid" + "github.com/jackc/pgx/v5" +) + +const rejectedRequest = "reject-template-audit" + +// auditFixture owns an isolated database whose audit tables reject rows with +// rejectedRequest, so the final audit insert of a mutation fails. +func auditFixture(t *testing.T) fixture { + t.Helper() + f := newFixture(t, pgtest.OpenIsolated(t, nil)) + _, err := f.pool.Exec(t.Context(), `CREATE FUNCTION reject_template_audit() RETURNS trigger LANGUAGE plpgsql AS $$ + BEGIN IF NEW.request_id = 'reject-template-audit' THEN RAISE EXCEPTION 'forced audit insertion failure'; END IF; RETURN NEW; END $$; + CREATE TRIGGER reject_template_audit BEFORE INSERT ON write_audit_operations FOR EACH ROW EXECUTE FUNCTION reject_template_audit(); + CREATE TRIGGER reject_template_admin_audit BEFORE INSERT ON admin_audit_log FOR EACH ROW EXECUTE FUNCTION reject_template_audit()`) + if err != nil { + t.Fatal(err) + } + return f +} + +func (f fixture) snapshot(t *testing.T) map[string]string { + t.Helper() + result := make(map[string]string) + for _, table := range []string{"environment_templates", "write_audit_operations", "write_audit_owners", "admin_audit_log"} { + var rows string + if err := f.pool.QueryRow(t.Context(), "SELECT COALESCE(jsonb_agg(to_jsonb(r) ORDER BY to_jsonb(r)::text)::text, '[]') FROM "+pgx.Identifier{table}.Sanitize()+" r").Scan(&rows); err != nil { + t.Fatalf("snapshot %s: %v", table, err) + } + result[table] = rows + } + return result +} + +func writeSource(ctx context.Context, tenant, request string) context.Context { + return writeaudit.WithSource(ctx, writeaudit.Source{ + KeyID: "static:" + strings.Repeat("a", 64), Name: "template audit fixture", Prefix: "aaaaaaaa", + Kind: "static", TenantID: tenant, RequestID: request, TraceID: "template-audit-trace", + }) +} + +// Each write records its audit operation in its own transaction: a failed +// audit insert leaves no Template change, and a successful write records the +// action, the Template ID and, for create, the new owner. +func TestWriteAuditCommitsWithTheTemplate(t *testing.T) { + f := auditFixture(t) + for _, action := range []string{"create", "update", "delete"} { + t.Run(action, func(t *testing.T) { + tenant := uuid.NewString() + existing := "" + if action != "create" { + existing = f.create(t, tenant, environmenttemplates.Input{}).ID + } + run := func(ctx context.Context) (string, error) { + switch action { + case "create": + v, err := f.service.Create(ctx, environmenttemplates.CreateCommand{TenantID: tenant}) + return v.ID, err + case "update": + v, err := f.service.Update(ctx, environmenttemplates.UpdateCommand{TenantID: tenant, TemplateID: existing, Input: environmenttemplates.Input{SetName: true, Name: ptr("replacement")}}) + return v.ID, err + } + return f.service.Delete(ctx, environmenttemplates.DeleteCommand{TenantID: tenant, TemplateID: existing}) + } + before := f.snapshot(t) + if _, err := run(writeSource(t.Context(), tenant, rejectedRequest)); err == nil { + t.Fatal("audit failure was accepted") + } + if !reflect.DeepEqual(before, f.snapshot(t)) { + t.Fatal("audit failure left Template or audit changes") + } + request := uuid.NewString() + id, err := run(writeSource(t.Context(), tenant, request)) + if err != nil { + t.Fatal(err) + } + var gotAction, kind, gotID string + var parent *string + if err := f.pool.QueryRow(t.Context(), `SELECT action,resource_type,resource_id,parent_id FROM write_audit_operations WHERE tenant_id=$1 AND request_id=$2`, tenant, request).Scan(&gotAction, &kind, &gotID, &parent); err != nil { + t.Fatal(err) + } + if gotAction != action || kind != "environment_template" || gotID != id || (parent != nil && *parent != "") { + t.Fatalf("wrong operation identity: %s %s %s", gotAction, kind, gotID) + } + owners, want := 0, 0 + if action == "create" { + want = 1 + } + if err := f.pool.QueryRow(t.Context(), `SELECT count(*) FROM write_audit_owners WHERE tenant_id=$1 AND resource_type='environment_template' AND resource_id=$2`, tenant, id).Scan(&owners); err != nil || owners != want { + t.Fatalf("ownership count %d, want %d: %v", owners, want, err) + } + }) + } +} + +func TestInvalidWriteAuditSourcePassesThrough(t *testing.T) { + f := newFixture(t, pgtest.Open(t)) + tenant := uuid.NewString() + _, err := f.service.Create(writeSource(t.Context(), uuid.NewString(), uuid.NewString()), environmenttemplates.CreateCommand{TenantID: tenant}) + if !errors.Is(err, writeaudit.ErrInvalidSource) { + t.Fatal("mismatched source tenant", err) + } + if page, err := f.keyless.List(t.Context(), tenant, environmenttemplates.ListQuery{Limit: 1}); err != nil || len(page.Templates) != 0 { + t.Fatal("rejected source left a Template", err) + } +} + +// An administrator deletion records only an administrator audit row, even +// under an inherited public-key source, and a failed audit restores the +// Template with its sealed configuration. +func TestAdminDeleteAuditCommitsWithTheDeletion(t *testing.T) { + f := auditFixture(t) + tenant := uuid.NewString() + template := f.create(t, tenant, environmenttemplates.Input{ + SetEnv: true, SetCommands: true, SetFiles: true, + Setup: 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")}}, + }) + // Administrator audit rows name the Project that owns the tenant. + if _, err := f.pool.Exec(t.Context(), "INSERT INTO execution_project_scopes(tenant_id,organization_id,project_id) VALUES($1,'admin-delete',$2)", tenant, tenant); err != nil { + t.Fatal(err) + } + if _, err := f.pool.Exec(t.Context(), "INSERT INTO projects(id,name,tenant_id,subject_kind,subject_id) VALUES($1,'Delete fixture',$1,'service_account',$2)", tenant, "project:"+tenant); err != nil { + t.Fatal(err) + } + admin := func(request string) context.Context { + return adminaudit.WithSource(writeSource(t.Context(), tenant, request), adminaudit.Source{ + CredentialID: "87654321", ActorLabel: "administrator fixture", ProjectID: tenant, RequestID: request, TraceID: "admin-mutation-trace", + }) + } + before := f.snapshot(t) + if _, err := f.service.Delete(admin(rejectedRequest), environmenttemplates.DeleteCommand{TenantID: tenant, TemplateID: template.ID}); err == nil { + t.Fatal("audit failure was accepted") + } + if !reflect.DeepEqual(before, f.snapshot(t)) { + t.Fatal("failed administrator audit changed the Template or audit rows") + } + if restored := f.resolve(t, tenant, template.ID); restored.Setup.Env["PRIVATE"] != "admin-private-env" || len(restored.Files) != 1 { + t.Fatal("failed deletion lost sealed configuration") + } + request := uuid.NewString() + id, err := f.service.Delete(admin(request), environmenttemplates.DeleteCommand{TenantID: tenant, TemplateID: template.ID}) + if err != nil || id != template.ID { + t.Fatal(id, err) + } + var credential, actor, project, action, kind, gotID, raw string + if err := f.pool.QueryRow(t.Context(), `SELECT admin_credential_id,actor_label,project_id,action,resource_type,resource_id,to_jsonb(a)::text FROM admin_audit_log a WHERE tenant_id=$1 AND request_id=$2`, tenant, request).Scan(&credential, &actor, &project, &action, &kind, &gotID, &raw); err != nil { + t.Fatal(err) + } + if credential != "87654321" || actor != "administrator fixture" || project != tenant || action != "delete" || kind != "environment_template" || gotID != id || strings.Contains(raw, "admin-private") { + t.Fatal("administrator audit identity differs") + } + var operations int + if err := f.pool.QueryRow(t.Context(), `SELECT count(*) FROM write_audit_operations WHERE tenant_id=$1 AND action='delete'`, tenant).Scan(&operations); err != nil || operations != 0 { + t.Fatal("administrator impersonated public-key provenance", err) + } + if _, err := f.keyless.Get(t.Context(), tenant, id); !errors.Is(err, environmenttemplates.ErrNotFound) { + t.Fatal("deleted Template is visible", err) + } +} diff --git a/services/core/internal/persistence/postgres/templatepg/seal.go b/services/core/internal/persistence/postgres/templatepg/seal.go new file mode 100644 index 00000000..fd504ecb --- /dev/null +++ b/services/core/internal/persistence/postgres/templatepg/seal.go @@ -0,0 +1,101 @@ +package templatepg + +import ( + "encoding/json" + + "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/environmenttemplates" + "github.com/google/uuid" + "github.com/jackc/pgx/v5/pgtype" +) + +// sealed is an Input's column values. Empty confidential fields are stored as +// NULL without using the key, so Templates without them need none; any other +// confidential field without a key fails with credentialcrypto.ErrUnavailable. +type sealed struct { + files, fileContents []byte + packages, env, commands []byte + skills, skillContents []byte + plugins, pluginContents []byte +} + +func (s *Store) seal(tenant, id pgtype.UUID, in environmenttemplates.Input) (sealed, error) { + var out sealed + var err error + if out.files, err = json.Marshal(environmentconfig.InitialFilesMetadata(in.Files)); err != nil { + return out, err + } + if len(in.Files) > 0 { + if s.cipher == nil { + return out, credentialcrypto.ErrUnavailable + } + plaintext, err := json.Marshal(in.Files) + if err != nil { + return out, err + } + if out.fileContents, err = s.cipher.SealEnvironmentFile(plaintext, fileBinding(tenant, id)); err != nil { + return out, err + } + } + if out.packages, err = json.Marshal(in.Setup.PackageMetadata()); err != nil { + return out, err + } + if out.skills, err = json.Marshal(in.Setup.SkillMetadata()); err != nil { + return out, err + } + if out.plugins, err = json.Marshal(in.Setup.PluginMetadata()); err != nil { + return out, err + } + for _, field := range []struct { + name string + value any + empty bool + output *[]byte + }{ + {"env", in.Setup.Env, len(in.Setup.Env) == 0, &out.env}, + {"setup_commands", in.Setup.Commands, len(in.Setup.Commands) == 0, &out.commands}, + {"skills", in.Setup.Skills, len(in.Setup.Skills) == 0, &out.skillContents}, + {"plugins", in.Setup.Plugins, len(in.Setup.Plugins) == 0, &out.pluginContents}, + } { + if field.empty { + continue + } + if s.cipher == nil { + return out, credentialcrypto.ErrUnavailable + } + plaintext, err := json.Marshal(field.value) + if err != nil { + return out, err + } + if *field.output, err = s.cipher.SealEnvironmentSetup(plaintext, setupBinding(tenant, id, field.name)); err != nil { + return out, err + } + } + return out, nil +} + +func (s *Store) openSetup(tenant, id pgtype.UUID, field string, ciphertext []byte, output any) error { + if len(ciphertext) == 0 { + return nil + } + if s.cipher == nil { + return credentialcrypto.ErrUnavailable + } + plaintext, err := s.cipher.OpenEnvironmentSetup(ciphertext, setupBinding(tenant, id, field)) + if err != nil { + return err + } + if environmentconfig.Decode(plaintext, output) != nil { + return environmenttemplates.ErrInvalidInput + } + return nil +} + +func setupBinding(tenant, id pgtype.UUID, field string) credentialcrypto.EnvironmentSetupBinding { + return credentialcrypto.EnvironmentSetupBinding{TenantID: uuid.UUID(tenant.Bytes).String(), Resource: resource, OwnerID: uuid.UUID(id.Bytes).String(), Field: field} +} + +func fileBinding(tenant, id pgtype.UUID) credentialcrypto.EnvironmentFileBinding { + return credentialcrypto.EnvironmentFileBinding{TenantID: uuid.UUID(tenant.Bytes).String(), Resource: resource, OwnerID: uuid.UUID(id.Bytes).String(), FileID: "files"} +} diff --git a/services/core/internal/persistence/postgres/templatepg/store.go b/services/core/internal/persistence/postgres/templatepg/store.go new file mode 100644 index 00000000..61f93bb4 --- /dev/null +++ b/services/core/internal/persistence/postgres/templatepg/store.go @@ -0,0 +1,272 @@ +// Package templatepg stores Environment Templates in PostgreSQL. Confidential +// configuration (env, setup commands, Skill and Plugin archives and initial +// file contents) is sealed with the credential key, each field bound to its +// tenant, Template and field; safe metadata is stored beside it in plaintext. +package templatepg + +import ( + "context" + "encoding/json" + "errors" + + "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/environmenttemplates" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/persistence/postgres/auditpg" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/persistence/postgres/pgunit" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/textvalue" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/writeaudit" + "github.com/google/uuid" + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgtype" +) + +// resource names Templates in credential bindings and audit rows. +const resource = "environment_template" + +// Store implements environmenttemplates.Storage and environmenttemplates.Reader. +type Store struct { + pool *pgunit.Pool + cipher *credentialcrypto.Cipher +} + +var ( + _ environmenttemplates.Storage = (*Store)(nil) + _ environmenttemplates.Reader = (*Store)(nil) +) + +// New returns a Store. Without a credential key (cipher nil), writes and +// resolutions that seal or open confidential configuration fail with +// credentialcrypto.ErrUnavailable; safe metadata stays readable. +func New(pool *pgunit.Pool, cipher *credentialcrypto.Cipher) *Store { + return &Store{pool: pool, cipher: cipher} +} + +func (s *Store) Create(ctx context.Context, tenantID string, in environmenttemplates.Input) (environmenttemplates.Template, error) { + tenant, err := pgunit.ParseID(tenantID) + if err != nil { + return environmenttemplates.Template{}, environmenttemplates.ErrInvalidInput + } + id := pgtype.UUID{Bytes: uuid.New(), Valid: true} + sealed, err := s.seal(tenant, id, in) + if err != nil { + return environmenttemplates.Template{}, err + } + var result environmenttemplates.Template + err = s.pool.Transaction(ctx, func(ctx context.Context, tx pgx.Tx) error { + q := sqlc.New(tx) + row, err := q.CreateEnvironmentTemplate(ctx, sqlc.CreateEnvironmentTemplateParams{ID: id, TenantID: tenant, Name: name(in.Name), NetworkAccess: in.NetworkAccess, NetworkAllowedDomains: append([]string{}, in.AllowedDomains...), Files: sealed.files, FileContents: sealed.fileContents, Packages: sealed.packages, EnvContents: sealed.env, SetupContents: sealed.commands, Skills: sealed.skills, SkillContents: sealed.skillContents, Plugins: sealed.plugins, PluginContents: sealed.pluginContents, CapabilityDirectories: append([]string{}, in.Setup.CapabilityDirectories...)}) + if err != nil { + return err + } + if result, err = template(metadataRow(row)); err != nil { + return err + } + return auditpg.RecordWriteAudit(ctx, q, tenantID, "create", resource, result.ID, "", writeaudit.Resource{Type: resource, ID: result.ID}) + }) + return result, storageError(err) +} + +// Update seals before it looks the Template up, so a malformed ID takes the +// path of a missing one. Each supplied field replaces atomically in one +// statement, so concurrent updates of other fields are kept. +func (s *Store) Update(ctx context.Context, tenantID, templateID string, in environmenttemplates.Input) (environmenttemplates.Template, error) { + tenant, err := pgunit.ParseID(tenantID) + if err != nil { + return environmenttemplates.Template{}, environmenttemplates.ErrInvalidInput + } + id := pgunit.PathID(templateID) + sealed, err := s.seal(tenant, id, in) + if err != nil { + return environmenttemplates.Template{}, err + } + var result environmenttemplates.Template + err = s.pool.Transaction(ctx, func(ctx context.Context, tx pgx.Tx) error { + q := sqlc.New(tx) + row, err := q.UpdateEnvironmentTemplate(ctx, sqlc.UpdateEnvironmentTemplateParams{TenantID: tenant, ID: id, Name: name(in.Name), SetName: in.SetName, NetworkAccess: in.NetworkAccess, NetworkAllowedDomains: append([]string{}, in.AllowedDomains...), SetNetwork: in.SetNetwork, SetFiles: in.SetFiles, Files: sealed.files, FileContents: sealed.fileContents, Packages: sealed.packages, EnvContents: sealed.env, SetupContents: sealed.commands, SetPackages: in.SetPackages, SetEnv: in.SetEnv, SetSetup: in.SetCommands, SetSkills: in.SetSkills, SetPlugins: in.SetPlugins, SetDirectories: in.SetDirectories, Skills: sealed.skills, SkillContents: sealed.skillContents, Plugins: sealed.plugins, PluginContents: sealed.pluginContents, CapabilityDirectories: append([]string{}, in.Setup.CapabilityDirectories...)}) + if err != nil { + return err + } + if result, err = template(metadataRow(row)); err != nil { + return err + } + return auditpg.RecordWriteAudit(ctx, q, tenantID, "update", resource, result.ID, "") + }) + return result, storageError(err) +} + +func (s *Store) Delete(ctx context.Context, tenantID, templateID string) (string, error) { + tenant, err := pgunit.ParseID(tenantID) + if err != nil { + return "", environmenttemplates.ErrInvalidInput + } + var deleted string + err = s.pool.Transaction(ctx, func(ctx context.Context, tx pgx.Tx) error { + q := sqlc.New(tx) + id, err := q.DeleteEnvironmentTemplate(ctx, sqlc.DeleteEnvironmentTemplateParams{TenantID: tenant, ID: pgunit.PathID(templateID)}) + if err != nil { + return err + } + deleted = uuid.UUID(id.Bytes).String() + return auditpg.RecordWriteAudit(ctx, q, tenantID, "delete", resource, deleted, "") + }) + if err != nil { + return "", storageError(err) + } + return deleted, nil +} + +func (s *Store) Get(ctx context.Context, tenantID, templateID string) (environmenttemplates.Template, error) { + tenant, err := pgunit.ParseID(tenantID) + if err != nil { + return environmenttemplates.Template{}, environmenttemplates.ErrInvalidInput + } + row, err := s.pool.Queries().GetEnvironmentTemplate(ctx, sqlc.GetEnvironmentTemplateParams{TenantID: tenant, ID: pgunit.PathID(templateID)}) + if err != nil { + return environmenttemplates.Template{}, storageError(err) + } + return template(metadataRow(row)) +} + +// List reads the cursor Template and the page in one snapshot. A cursor that +// names no Template of the tenant returns ErrNotFound. +func (s *Store) List(ctx context.Context, tenantID string, query environmenttemplates.ListQuery) (environmenttemplates.Page, error) { + tenant, err := pgunit.ParseID(tenantID) + if err != nil { + return environmenttemplates.Page{}, environmenttemplates.ErrInvalidInput + } + if err := query.Validate(); err != nil { + return environmenttemplates.Page{}, err + } + params := sqlc.ListEnvironmentTemplatesParams{TenantID: tenant, PageLimit: int32(query.Limit + 1), AfterID: pgtype.UUID{Valid: true}, Ascending: query.Ascending} + var rows []sqlc.ListEnvironmentTemplatesRow + err = s.pool.Snapshot(ctx, func(ctx context.Context, tx pgx.Tx) error { + q := sqlc.New(tx) + if query.After != "" { + after, err := q.GetEnvironmentTemplate(ctx, sqlc.GetEnvironmentTemplateParams{TenantID: tenant, ID: pgunit.PathID(pgunit.LookupCursor(query.After))}) + if err != nil { + return err + } + params.AfterCreated, params.AfterID = after.CreatedAt, after.ID + } + var err error + rows, err = q.ListEnvironmentTemplates(ctx, params) + return err + }) + if err != nil { + return environmenttemplates.Page{}, storageError(err) + } + page := environmenttemplates.Page{Templates: make([]environmenttemplates.Template, 0, min(query.Limit, len(rows))), HasMore: len(rows) > query.Limit} + for _, row := range rows[:min(query.Limit, len(rows))] { + value, err := template(metadataRow(row)) + if err != nil { + return environmenttemplates.Page{}, err + } + page.Templates = append(page.Templates, value) + } + return page, nil +} + +// Resolve reads one row, so the metadata and every confidential field come +// from the same committed version. Stored Skill and Plugin metadata must match +// the sealed archives they describe. Any malformed ID resolves to ErrNotFound. +func (s *Store) Resolve(ctx context.Context, tenantID, templateID string) (environmenttemplates.Resolved, error) { + tenant, err := pgunit.ParseID(tenantID) + if err != nil { + return environmenttemplates.Resolved{}, environmenttemplates.ErrNotFound + } + id, err := pgunit.ParseID(templateID) + if err != nil { + return environmenttemplates.Resolved{}, environmenttemplates.ErrNotFound + } + row, err := s.pool.Queries().ResolveEnvironmentTemplate(ctx, sqlc.ResolveEnvironmentTemplateParams{TenantID: tenant, ID: id}) + if err != nil { + return environmenttemplates.Resolved{}, storageError(err) + } + metadata, err := template(metadataRow{ID: row.ID, TenantID: row.TenantID, Name: row.Name, NetworkAccess: row.NetworkAccess, NetworkAllowedDomains: row.NetworkAllowedDomains, CreatedAt: row.CreatedAt, UpdatedAt: row.UpdatedAt, Files: row.Files, Packages: row.Packages, Skills: row.Skills, Plugins: row.Plugins, CapabilityDirectories: row.CapabilityDirectories}) + if err != nil { + return environmenttemplates.Resolved{}, err + } + resolved := environmenttemplates.Resolved{Template: metadata, Setup: environmentconfig.Setup{Packages: metadata.Packages, CapabilityDirectories: append([]string(nil), metadata.CapabilityDirectories...)}} + for _, field := range []struct { + name string + ciphertext []byte + output any + }{ + {"env", row.EnvContents, &resolved.Setup.Env}, + {"setup_commands", row.SetupContents, &resolved.Setup.Commands}, + {"skills", row.SkillContents, &resolved.Setup.Skills}, + {"plugins", row.PluginContents, &resolved.Setup.Plugins}, + } { + if err := s.openSetup(tenant, id, field.name, field.ciphertext, field.output); err != nil { + return environmenttemplates.Resolved{}, err + } + } + if !matches(metadata.Skills, resolved.Setup.SkillMetadata()) || !matches(metadata.Plugins, resolved.Setup.PluginMetadata()) || resolved.Setup.Validate() != nil { + return environmenttemplates.Resolved{}, environmenttemplates.ErrInvalidInput + } + if len(row.FileContents) == 0 { + if len(metadata.Files) > 0 { + return environmenttemplates.Resolved{}, environmenttemplates.ErrInvalidInput + } + return resolved, nil + } + if s.cipher == nil { + return environmenttemplates.Resolved{}, credentialcrypto.ErrUnavailable + } + plaintext, err := s.cipher.OpenEnvironmentFile(row.FileContents, fileBinding(tenant, id)) + if err != nil { + return environmenttemplates.Resolved{}, err + } + if json.Unmarshal(plaintext, &resolved.Files) != nil || environmentconfig.ValidateInitialFiles(resolved.Files) != nil { + return environmenttemplates.Resolved{}, environmenttemplates.ErrInvalidInput + } + return resolved, nil +} + +func matches[T comparable](stored, sealed []T) bool { + if len(stored) != len(sealed) { + return false + } + for i := range stored { + if stored[i] != sealed[i] { + return false + } + } + return true +} + +// metadataRow is the safe column set every Template query returns. +type metadataRow sqlc.GetEnvironmentTemplateRow + +func template(row metadataRow) (environmenttemplates.Template, error) { + result := environmenttemplates.Template{ID: uuid.UUID(row.ID.Bytes).String(), NetworkAccess: row.NetworkAccess, AllowedDomains: append([]string{}, row.NetworkAllowedDomains...), CapabilityDirectories: append([]string{}, row.CapabilityDirectories...), CreatedAt: row.CreatedAt.Time, UpdatedAt: row.UpdatedAt.Time} + if row.Name.Valid { + result.Name = &row.Name.String + } + 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 environmenttemplates.Template{}, environmenttemplates.ErrInvalidInput + } + return result, nil +} + +func name(value *string) pgtype.Text { + if value == nil { + return pgtype.Text{} + } + return pgtype.Text{String: *value, Valid: true} +} + +// storageError translates the database outcomes callers act on: no row is +// ErrNotFound and text PostgreSQL cannot store is textvalue.ErrUnstorable. +// Every other error returns as is. +func storageError(err error) error { + switch { + case errors.Is(err, pgx.ErrNoRows): + return environmenttemplates.ErrNotFound + case pgunit.IsUnstorableText(err): + return textvalue.ErrUnstorable + } + return err +} diff --git a/services/core/internal/persistence/postgres/templatepg/store_test.go b/services/core/internal/persistence/postgres/templatepg/store_test.go new file mode 100644 index 00000000..c5c5c1c6 --- /dev/null +++ b/services/core/internal/persistence/postgres/templatepg/store_test.go @@ -0,0 +1,390 @@ +package templatepg_test + +import ( + "archive/zip" + "bytes" + "errors" + "reflect" + "strings" + "sync" + "testing" + + v1 "github.com/MiniMax-AI/OpenAgentCore/contracts/agents-api/v1" + "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/environmenttemplates" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/persistence/postgres/pgtest" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/persistence/postgres/pgunit" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/persistence/postgres/templatepg" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/textvalue" + "github.com/google/uuid" + "github.com/jackc/pgx/v5/pgxpool" +) + +// fixture is a Template adapter with the credential key and one without it, +// on the same database, plus the service that validates writes. +type fixture struct { + pool *pgxpool.Pool + keyed *templatepg.Store + keyless *templatepg.Store + service *environmenttemplates.Service +} + +func newFixture(t *testing.T, pool *pgxpool.Pool) fixture { + t.Helper() + cipher, err := credentialcrypto.New(bytes.Repeat([]byte{29}, 32)) + if err != nil { + t.Fatal(err) + } + keyed := templatepg.New(pgunit.NewPool(pool), cipher) + service, err := environmenttemplates.NewService(keyed) + if err != nil { + t.Fatal(err) + } + return fixture{pool: pool, keyed: keyed, keyless: templatepg.New(pgunit.NewPool(pool), nil), service: service} +} + +func (f fixture) create(t *testing.T, tenant string, in environmenttemplates.Input) environmenttemplates.Template { + t.Helper() + created, err := f.service.Create(t.Context(), environmenttemplates.CreateCommand{TenantID: tenant, Input: in}) + if err != nil { + t.Fatal(err) + } + return created +} + +func (f fixture) update(t *testing.T, tenant, id string, in environmenttemplates.Input) environmenttemplates.Template { + t.Helper() + updated, err := f.service.Update(t.Context(), environmenttemplates.UpdateCommand{TenantID: tenant, TemplateID: id, Input: in}) + if err != nil { + t.Fatal(err) + } + return updated +} + +func (f fixture) resolve(t *testing.T, tenant, id string) environmenttemplates.Resolved { + t.Helper() + resolved, err := f.keyed.Resolve(t.Context(), tenant, id) + if err != nil { + t.Fatal(err) + } + return resolved +} + +// ciphertext reads one confidential column and fails when it holds plaintext. +func (f fixture) requireSealed(t *testing.T, id, column, canary string) { + t.Helper() + var metadata, sealed []byte + query := "SELECT to_jsonb(t) - '" + column + "', " + column + " FROM environment_templates t WHERE id=$1" + if err := f.pool.QueryRow(t.Context(), query, id).Scan(&metadata, &sealed); err != nil || len(sealed) == 0 || bytes.Contains(metadata, []byte(canary)) || bytes.Contains(sealed, []byte(canary)) { + t.Fatalf("%s is not sealed: %v", column, err) + } +} + +func ptr(value string) *string { return &value } + +func TestTemplatesDurableIsolatedAndConcurrent(t *testing.T) { + f := newFixture(t, pgtest.Open(t)) + ctx := t.Context() + tenant, foreign := uuid.NewString(), uuid.NewString() + created := f.create(t, tenant, environmenttemplates.Input{Name: ptr(" template ")}) + if created.NetworkAccess != "enabled" || created.Name == nil || *created.Name != " template " || !created.CreatedAt.Equal(created.UpdatedAt) { + t.Fatal(created) + } + if _, err := f.keyed.Get(ctx, foreign, created.ID); !errors.Is(err, environmenttemplates.ErrNotFound) { + t.Fatal("foreign read", err) + } + if _, err := f.service.Update(ctx, environmenttemplates.UpdateCommand{TenantID: foreign, TemplateID: created.ID, Input: environmenttemplates.Input{SetName: true}}); !errors.Is(err, environmenttemplates.ErrNotFound) { + t.Fatal("foreign update", err) + } + if _, err := f.service.Delete(ctx, environmenttemplates.DeleteCommand{TenantID: foreign, TemplateID: created.ID}); !errors.Is(err, environmenttemplates.ErrNotFound) { + t.Fatal("foreign delete", err) + } + if _, err := f.keyed.List(ctx, foreign, environmenttemplates.ListQuery{After: created.ID, Limit: 1}); !errors.Is(err, environmenttemplates.ErrNotFound) { + t.Fatal("foreign cursor", err) + } + var wg sync.WaitGroup + for _, in := range []environmenttemplates.Input{{Name: ptr("changed"), SetName: true}, {NetworkAccess: "disabled", SetNetwork: true}} { + wg.Add(1) + go func() { + defer wg.Done() + if _, err := f.service.Update(ctx, environmenttemplates.UpdateCommand{TenantID: tenant, TemplateID: created.ID, Input: in}); err != nil { + t.Error(err) + } + }() + } + wg.Wait() + got, err := f.keyed.Get(ctx, tenant, created.ID) + if err != nil || got.NetworkAccess != "disabled" || got.Name == nil || *got.Name != "changed" || !got.CreatedAt.Equal(created.CreatedAt) { + t.Fatal("lost concurrent update", got, err) + } + f.pool.Close() + f = newFixture(t, pgtest.Open(t)) + if got, err = f.keyed.Get(ctx, tenant, created.ID); err != nil || got.NetworkAccess != "disabled" { + t.Fatal("lost durable template", got, err) + } + ids := []string{created.ID} + for range 3 { + ids = append(ids, f.create(t, tenant, environmenttemplates.Input{}).ID) + } + first, err := f.keyed.List(ctx, tenant, environmenttemplates.ListQuery{Limit: 2, Ascending: true}) + if err != nil || !first.HasMore || len(first.Templates) != 2 || first.Templates[0].ID != ids[0] { + t.Fatal(first, err) + } + second, err := f.keyed.List(ctx, tenant, environmenttemplates.ListQuery{After: first.Templates[1].ID, Limit: 2, Ascending: true}) + if err != nil || second.HasMore || len(second.Templates) != 2 || second.Templates[0].ID != ids[2] { + t.Fatal(second, err) + } + reverse, err := f.keyed.List(ctx, tenant, environmenttemplates.ListQuery{Limit: 1}) + if err != nil || reverse.Templates[0].ID != ids[3] { + t.Fatal(reverse, err) + } + if _, err := f.keyed.List(ctx, tenant, environmenttemplates.ListQuery{Limit: environmenttemplates.MaxListLimit + 1}); !errors.Is(err, environmenttemplates.ErrInvalidInput) { + t.Fatal("oversized page", err) + } + cleared := f.update(t, tenant, created.ID, environmenttemplates.Input{SetName: true, SetNetwork: true, NetworkAccess: "enabled"}) + if cleared.Name != nil || cleared.NetworkAccess != "enabled" { + t.Fatal(cleared) + } + if id, err := f.service.Delete(ctx, environmenttemplates.DeleteCommand{TenantID: tenant, TemplateID: created.ID}); err != nil || id != created.ID { + t.Fatal(id, err) + } + if _, err := f.keyed.Get(ctx, tenant, created.ID); !errors.Is(err, environmenttemplates.ErrNotFound) { + t.Fatal(err) + } +} + +func TestEmptyUpdateTouchesTimeWithoutKeyOrContents(t *testing.T) { + f := newFixture(t, pgtest.Open(t)) + tenant := uuid.NewString() + original := f.create(t, tenant, environmenttemplates.Input{ + Name: ptr("Retained template"), + Files: []environmentconfig.InitialFile{{Type: "inline", Path: "/workspace/input.txt", Data: []byte("file-canary")}}, + Setup: environmentconfig.Setup{Env: map[string]string{"PRIVATE_SETUP": "env-canary"}, Commands: []environmentconfig.SetupCommand{{Command: "echo setup-canary"}}}, + }) + row := func() []byte { + t.Helper() + var contents []byte + if err := f.pool.QueryRow(t.Context(), "SELECT to_jsonb(t) - 'updated_at' FROM environment_templates t WHERE id=$1", original.ID).Scan(&contents); err != nil { + t.Fatal(err) + } + return contents + } + before := row() + if _, err := f.keyless.Update(t.Context(), uuid.NewString(), original.ID, environmenttemplates.Input{}); !errors.Is(err, environmenttemplates.ErrNotFound) { + t.Fatal("foreign empty update was admitted", err) + } + updated, err := f.keyless.Update(t.Context(), tenant, original.ID, environmenttemplates.Input{}) + if err != nil || !updated.UpdatedAt.After(original.UpdatedAt) { + t.Fatal("empty update did not advance the timestamp without a key", err) + } + original.UpdatedAt = updated.UpdatedAt + if !reflect.DeepEqual(updated, original) || !bytes.Equal(before, row()) { + t.Fatal("empty update changed metadata, ciphertext or ownership") + } + if retained := f.resolve(t, tenant, original.ID); retained.Setup.Env["PRIVATE_SETUP"] != "env-canary" || len(retained.Setup.Commands) != 1 || len(retained.Files) != 1 { + t.Fatal("empty update invalidated confidential configuration") + } +} + +func TestNetworkPolicyRoundTripAndReplacement(t *testing.T) { + f := newFixture(t, pgtest.Open(t)) + ctx := t.Context() + tenant, foreign := uuid.NewString(), uuid.NewString() + domains := []string{"Example.com", "api.example.com", "Example.com"} + check := func(value environmenttemplates.Template, err error) { + t.Helper() + if err != nil || value.NetworkAccess != "restricted" || !reflect.DeepEqual(value.AllowedDomains, domains) { + t.Fatalf("network policy lost: %#v, %v", value, err) + } + } + created := f.create(t, tenant, environmenttemplates.Input{SetNetwork: true, NetworkAccess: "restricted", AllowedDomains: domains}) + check(created, nil) + check(f.keyless.Get(ctx, tenant, created.ID)) + check(f.resolve(t, tenant, created.ID).Template, nil) + page, err := f.keyless.List(ctx, tenant, environmenttemplates.ListQuery{Limit: 1, Ascending: true}) + if err != nil || len(page.Templates) != 1 { + t.Fatal(page, err) + } + check(page.Templates[0], nil) + check(f.update(t, tenant, created.ID, environmenttemplates.Input{SetName: true, Name: ptr("renamed")}), nil) + if _, err := f.keyed.Resolve(ctx, foreign, created.ID); !errors.Is(err, environmenttemplates.ErrNotFound) { + t.Fatal("foreign policy resolution", err) + } + for _, in := range []environmenttemplates.Input{ + {SetNetwork: true, NetworkAccess: "enabled", AllowedDomains: domains}, + {SetNetwork: true, NetworkAccess: "restricted"}, + {SetNetwork: true, NetworkAccess: "restricted", AllowedDomains: []string{"*.example.com"}}, + } { + if _, err := f.service.Update(ctx, environmenttemplates.UpdateCommand{TenantID: tenant, TemplateID: created.ID, Input: in}); !errors.Is(err, environmenttemplates.ErrInvalidInput) { + t.Fatal("invalid policy replacement", err) + } + check(f.keyless.Get(ctx, tenant, created.ID)) + } + domains = []string{"other.example.com"} + check(f.update(t, tenant, created.ID, environmenttemplates.Input{SetNetwork: true, NetworkAccess: "restricted", AllowedDomains: domains}), nil) + for _, access := range []string{"disabled", "enabled"} { + value := f.update(t, tenant, created.ID, environmenttemplates.Input{SetNetwork: true, NetworkAccess: access}) + if value.NetworkAccess != access || value.AllowedDomains == nil || len(value.AllowedDomains) != 0 { + t.Fatal("policy reset", value) + } + } +} + +func TestSetupSealedAndReplacedPerField(t *testing.T) { + f := newFixture(t, pgtest.Open(t)) + tenant, foreign := uuid.NewString(), uuid.NewString() + 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 := f.create(t, tenant, environmenttemplates.Input{Setup: setup, SetEnv: true, SetCommands: true, SetPackages: true}) + public, err := f.keyless.Get(t.Context(), tenant, template.ID) + if err != nil || !reflect.DeepEqual(public.Packages.NPM, setup.Packages.NPM) { + t.Fatal("public metadata requires plaintext or key", err) + } + resolved := f.resolve(t, tenant, template.ID) + if !reflect.DeepEqual(resolved.Setup.Env, setup.Env) || !reflect.DeepEqual(resolved.Setup.Commands, setup.Commands) || !reflect.DeepEqual(resolved.Setup.Packages.NPM, setup.Packages.NPM) { + t.Fatal("confidential configuration resolution", resolved.Setup) + } + if _, err := f.keyed.Resolve(t.Context(), foreign, template.ID); !errors.Is(err, environmenttemplates.ErrNotFound) { + t.Fatal("foreign resolution", err) + } + f.requireSealed(t, template.ID, "env_contents", "template-env-canary") + f.requireSealed(t, template.ID, "setup_contents", "template-command-canary") + f.update(t, tenant, template.ID, environmenttemplates.Input{SetName: true, Name: ptr("renamed")}) + if retained := f.resolve(t, tenant, template.ID); !reflect.DeepEqual(retained.Setup, resolved.Setup) { + t.Fatal("name update changed setup") + } + f.update(t, tenant, template.ID, environmenttemplates.Input{SetEnv: true}) + if cleared := f.resolve(t, tenant, template.ID); len(cleared.Setup.Env) != 0 || !reflect.DeepEqual(cleared.Setup.Commands, setup.Commands) { + t.Fatal("field replacement lost unrelated values") + } +} + +func TestInitialFilesSealedWithNoncanonicalIDs(t *testing.T) { + f := newFixture(t, pgtest.Open(t)) + tenant, foreign := uuid.NewString(), uuid.NewString() + canary := []byte("private-initial-file-canary\x00\xff") + files := []environmentconfig.InitialFile{{Type: "inline", Path: "/workspace/a/data", Data: canary}, {Type: "file_id", Path: "/workspace/b", FileID: "file-" + uuid.NewString()}} + template := f.create(t, tenant, environmenttemplates.Input{SetFiles: true, Files: files}) + public, err := f.keyless.Get(t.Context(), tenant, template.ID) + if err != nil || len(public.Files) != 2 || *public.Files[0].SizeBytes != int64(len(canary)) { + t.Fatal("public read depends on the key", err) + } + if _, err := f.keyed.Resolve(t.Context(), foreign, template.ID); !errors.Is(err, environmenttemplates.ErrNotFound) { + t.Fatal("foreign template resolved", err) + } + f.update(t, strings.ToUpper(tenant), strings.ToUpper(template.ID), environmenttemplates.Input{SetFiles: true, Files: files}) + if resolved := f.resolve(t, strings.ToUpper(tenant), strings.ToUpper(template.ID)); !reflect.DeepEqual(resolved.Files, files) { + t.Fatal("noncanonical resolution") + } + f.requireSealed(t, template.ID, "file_contents", "private-initial-file-canary") + f.update(t, tenant, template.ID, environmenttemplates.Input{SetFiles: true}) + if cleared := f.resolve(t, tenant, template.ID); len(cleared.Files) != 0 || len(cleared.Template.Files) != 0 { + t.Fatal("files were not cleared") + } +} + +func TestSkillsAndPluginsSealedAndPreserved(t *testing.T) { + f := newFixture(t, pgtest.Open(t)) + ctx := t.Context() + setup := environmentconfig.Setup{ + Skills: []environmentconfig.Skill{{Metadata: environmentconfig.SkillMetadata{Type: "inline", Name: "proof", Description: "A proof."}, Archive: archive(t, map[string]string{"proof/SKILL.md": "---\nname: proof\ndescription: A proof.\n---\nskill-private-canary"})}}, + Plugins: []environmentconfig.Plugin{{Metadata: agentplugin.Metadata{Type: "inline", Name: "plugin-proof", Description: "A proof."}, Archive: archive(t, map[string]string{"proof/.codex-plugin/plugin.json": `{"name":"plugin-proof","description":"A proof.","skills":"./skills"}`, "proof/skills/proof/SKILL.md": "---\nname: proof\ndescription: A proof.\n---\nplugin-private-canary"})}}, + CapabilityDirectories: []string{"/workspace/generated"}, + } + tenant, foreign := uuid.NewString(), uuid.NewString() + template := f.create(t, tenant, environmenttemplates.Input{SetSkills: true, SetPlugins: true, SetDirectories: true, Setup: setup}) + public, err := f.keyless.Get(ctx, tenant, template.ID) + if err != nil || len(public.Skills) != 1 || len(public.Plugins) != 1 || !reflect.DeepEqual(public.CapabilityDirectories, setup.CapabilityDirectories) { + t.Fatal("safe metadata without the key", err) + } + if page, err := f.keyless.List(ctx, tenant, environmenttemplates.ListQuery{Limit: 20}); err != nil || len(page.Templates) != 1 || len(page.Templates[0].Plugins) != 1 { + t.Fatal("list", err) + } + f.requireSealed(t, template.ID, "skill_contents", "skill-private-canary") + f.requireSealed(t, template.ID, "plugin_contents", "plugin-private-canary") + if resolved := f.resolve(t, tenant, template.ID); !reflect.DeepEqual(resolved.Setup.Skills, setup.Skills) || !reflect.DeepEqual(resolved.Setup.Plugins, setup.Plugins) { + t.Fatal("resolution") + } + for _, call := range []func() error{ + func() error { _, e := f.keyed.Get(ctx, foreign, template.ID); return e }, + func() error { _, e := f.keyed.Resolve(ctx, foreign, template.ID); return e }, + func() error { + _, e := f.keyed.Update(ctx, foreign, template.ID, environmenttemplates.Input{SetPlugins: true}) + return e + }, + } { + if err := call(); !errors.Is(err, environmenttemplates.ErrNotFound) { + t.Fatal("tenant isolation", err) + } + } + f.update(t, tenant, template.ID, environmenttemplates.Input{SetName: true, Name: ptr("renamed")}) + if preserved := f.resolve(t, tenant, template.ID); !reflect.DeepEqual(preserved.Setup.Skills, setup.Skills) || !reflect.DeepEqual(preserved.Setup.Plugins, setup.Plugins) || !reflect.DeepEqual(preserved.Setup.CapabilityDirectories, setup.CapabilityDirectories) { + t.Fatal("unrelated update changed installation") + } + f.update(t, tenant, template.ID, environmenttemplates.Input{SetSkills: true, SetPlugins: true, SetDirectories: true}) + if cleared := f.resolve(t, tenant, template.ID); len(cleared.Setup.Skills) != 0 || len(cleared.Setup.Plugins) != 0 || len(cleared.Template.CapabilityDirectories) != 0 { + t.Fatal("clear") + } +} + +// A Template saves a Skill reference as the caller selected it. Session +// creation, not the Template, resolves the selector to a version, so deleting +// or re-versioning the Skill never changes the Template. +func TestSkillReferenceKeepsSelector(t *testing.T) { + f := newFixture(t, pgtest.Open(t)) + tenant := uuid.NewString() + reference := environmentconfig.Skill{Metadata: environmentconfig.SkillMetadata{Type: "skill_reference", SkillID: "skill_" + uuid.NewString()}} + template := f.create(t, tenant, environmenttemplates.Input{SetSkills: true, Setup: environmentconfig.Setup{Skills: []environmentconfig.Skill{reference}}}) + resolved := f.resolve(t, tenant, template.ID) + if len(resolved.Setup.Skills) != 1 || resolved.Setup.Skills[0].Metadata != reference.Metadata || len(resolved.Setup.Skills[0].Archive) != 0 || resolved.Template.Skills[0].Version != "" { + t.Fatal("template resolved a mutable selector", resolved.Setup.Skills) + } +} + +func TestUnstorableTextIsRejected(t *testing.T) { + f := newFixture(t, pgtest.Open(t)) + tenant := uuid.NewString() + if _, err := f.service.Create(t.Context(), environmenttemplates.CreateCommand{TenantID: tenant, Input: environmenttemplates.Input{Name: ptr("a\x00b")}}); !errors.Is(err, textvalue.ErrUnstorable) { + t.Fatal("create", err) + } + template := f.create(t, tenant, environmenttemplates.Input{}) + if _, err := f.service.Update(t.Context(), environmenttemplates.UpdateCommand{TenantID: tenant, TemplateID: template.ID, Input: environmenttemplates.Input{SetName: true, Name: ptr("a\x00b")}}); !errors.Is(err, textvalue.ErrUnstorable) { + t.Fatal("update", err) + } +} + +func TestMissingKeyIsUnavailable(t *testing.T) { + f := newFixture(t, pgtest.Open(t)) + tenant := uuid.NewString() + setup := environmenttemplates.Input{Setup: environmentconfig.Setup{Env: map[string]string{"PRIVATE_SETUP": "env-canary"}}} + files := environmenttemplates.Input{SetFiles: true, Files: []environmentconfig.InitialFile{{Type: "inline", Path: "/workspace/input.txt", Data: []byte("file-canary")}}} + for _, in := range []environmenttemplates.Input{setup, files} { + if _, err := f.keyless.Create(t.Context(), tenant, in); !errors.Is(err, credentialcrypto.ErrUnavailable) { + t.Fatal("confidential create without a key", err) + } + template := f.create(t, tenant, in) + if _, err := f.keyless.Resolve(t.Context(), tenant, template.ID); !errors.Is(err, credentialcrypto.ErrUnavailable) { + t.Fatal("confidential resolve without a key", err) + } + } +} + +func archive(t *testing.T, files map[string]string) []byte { + t.Helper() + var buffer bytes.Buffer + writer := zip.NewWriter(&buffer) + for name, body := range files { + file, err := writer.CreateHeader(&zip.FileHeader{Name: name, Method: zip.Store}) + if err != nil { + t.Fatal(err) + } + if _, err = file.Write([]byte(body)); err != nil { + t.Fatal(err) + } + } + if err := writer.Close(); err != nil { + t.Fatal(err) + } + return buffer.Bytes() +} From 3b2afd02fa727d571d7194abd0e4a117822eacf0 Mon Sep 17 00:00:00 2001 From: SaladDay <1203511142@qq.com> Date: Wed, 30 Sep 2026 16:34:34 +0000 Subject: [PATCH 2/2] Serve Environment Templates from the environmenttemplates domain api reads Templates through EnvironmentTemplatesReader and writes them through the EnvironmentTemplates service; Session creation resolves its Template through the Reader. cmd/server wires templatepg. The store Template operations, types and tests are deleted. --- services/core/IMPLEMENTATION.md | 1 + services/core/cmd/server/http_routes_test.go | 3 +- services/core/cmd/server/main.go | 10 +- services/core/internal/api/dependencies.go | 5 +- .../core/internal/api/dependencies_test.go | 8 +- .../internal/api/environment_network_test.go | 8 +- .../internal/api/environment_plugins_test.go | 9 +- .../api/environment_preparation_input_test.go | 4 +- .../internal/api/environment_setup_test.go | 6 +- .../api/environment_skill_selectors_test.go | 11 +- .../internal/api/environment_skills_test.go | 11 +- .../internal/api/environment_templates.go | 64 ++--- .../api/environment_templates_test.go | 71 +++-- .../api/errors_environmenttemplates.go | 24 ++ services/core/internal/api/fakes_test.go | 68 ++--- services/core/internal/api/handler.go | 17 +- .../internal/api/hosted_structured_test.go | 11 +- .../internal/api/resource_creation_test.go | 8 +- .../core/internal/api/resource_query_test.go | 22 +- .../core/internal/api/session_template.go | 24 +- .../api/session_template_composition_test.go | 59 ++--- .../api/session_template_null_test.go | 19 +- .../internal/api/validation_errors_test.go | 11 +- .../postgres/templatepg/audit_test.go | 10 +- .../postgres/templatepg/store_test.go | 31 +++ .../providers/configuration_flow_test.go | 3 +- .../internal/store/admin_delete_audit_test.go | 18 +- .../store/environment_network_test.go | 58 ---- .../store/environment_packages_test.go | 8 - .../internal/store/environment_plugins.go | 16 -- .../store/environment_plugins_test.go | 54 +--- .../core/internal/store/environment_setup.go | 20 +- .../internal/store/environment_setup_test.go | 44 +--- .../environment_skill_references_test.go | 13 +- .../core/internal/store/environment_skills.go | 16 -- .../internal/store/environment_skills_test.go | 38 +-- .../internal/store/environment_templates.go | 248 ------------------ .../store/environment_templates_noop_test.go | 57 ---- .../store/environment_templates_test.go | 88 ------- services/core/internal/store/initial_files.go | 85 ------ .../core/internal/store/initial_files_test.go | 29 +- .../store/public_handler_fixture_test.go | 10 +- .../store/skill_version_deletion_test.go | 10 +- .../store/write_audit_resources_test.go | 21 +- 44 files changed, 343 insertions(+), 1008 deletions(-) create mode 100644 services/core/internal/api/errors_environmenttemplates.go delete mode 100644 services/core/internal/store/environment_network_test.go delete mode 100644 services/core/internal/store/environment_plugins.go delete mode 100644 services/core/internal/store/environment_skills.go delete mode 100644 services/core/internal/store/environment_templates.go delete mode 100644 services/core/internal/store/environment_templates_noop_test.go delete mode 100644 services/core/internal/store/environment_templates_test.go diff --git a/services/core/IMPLEMENTATION.md b/services/core/IMPLEMENTATION.md index da293e57..e9f2ffc5 100644 --- a/services/core/IMPLEMENTATION.md +++ b/services/core/IMPLEMENTATION.md @@ -25,6 +25,7 @@ Domain owners, each with its PostgreSQL adapter under `internal/persistence/post - `agents` (`agentpg`): saved Agents, their configuration merge and bounds, and the encrypted model-provider bundle bound to each Agent. - `files` (`filepg`): source Files. - `vaults` (`vaultpg`): Vaults and Credentials, the encryption of Credential secrets, OAuth access-token refresh, and the MCP credential selection that Session creation freezes and the Dispatcher's `Credentials` resolves into a bearer token. +- `environmenttemplates` (`templatepg`): Environment Templates, their validation and default network, their sealed setup, initial files, Skills and Plugins, and the resolved Template that Session creation composes into its Environment. ## Request handling diff --git a/services/core/cmd/server/http_routes_test.go b/services/core/cmd/server/http_routes_test.go index e6c96873..c9babc8a 100644 --- a/services/core/cmd/server/http_routes_test.go +++ b/services/core/cmd/server/http_routes_test.go @@ -89,8 +89,9 @@ func daemonComposition(t testing.TB) http.Handler { Engine: "codex", CoreKeys: admin, InstallationBindings: struct{ api.InstallationBindings }{}, Projects: trapProjects{keys: keys}, ModelProviders: struct{ api.ModelProviders }{}, Vaults: struct{ api.Vaults }{}, VaultsReader: struct{ api.VaultsReader }{}, - Files: struct{ api.Files }{}, FilesReader: struct{ api.FilesReader }{}, Skills: struct{ api.Skills }{}, EnvironmentTemplates: struct{ api.EnvironmentTemplates }{}, + Files: struct{ api.Files }{}, FilesReader: struct{ api.FilesReader }{}, Skills: struct{ api.Skills }{}, Agents: struct{ api.Agents }{}, AgentsReader: struct{ api.AgentsReader }{}, Sessions: struct{ api.Sessions }{}, SessionEvents: struct{ api.SessionEvents }{}, + EnvironmentTemplates: struct{ api.EnvironmentTemplates }{}, EnvironmentTemplatesReader: struct{ api.EnvironmentTemplatesReader }{}, SessionHistory: struct{ api.SessionHistory }{}, Subagents: struct{ api.Subagents }{}, Artifacts: struct{ api.Artifacts }{}, SessionAdmin: struct{ api.SessionAdmin }{}, Environments: struct{ api.Environments }{}, ExecutorConnections: struct{ api.ExecutorConnections }{}, Admin: struct{ api.Admin }{}, AdminAudit: struct{ api.AdminAudit }{}, WriteAudit: struct{ api.WriteAudit }{}, Metrics: struct{ api.Metrics }{}, diff --git a/services/core/cmd/server/main.go b/services/core/cmd/server/main.go index 9e4a32f4..397f9ea6 100644 --- a/services/core/cmd/server/main.go +++ b/services/core/cmd/server/main.go @@ -35,6 +35,7 @@ import ( "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/api" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/coremetrics" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/databaseurl" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/environmenttemplates" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/execution" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/files" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/nativeinstaller" @@ -42,6 +43,7 @@ import ( "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/persistence/postgres/auditpg" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/persistence/postgres/filepg" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/persistence/postgres/pgunit" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/persistence/postgres/templatepg" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/persistence/postgres/vaultpg" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/runtime" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/runtimeenrollment" @@ -125,6 +127,11 @@ func run() error { if err != nil { return err } + templateStore := templatepg.New(units, credentialKey) + environmentTemplates, err := environmenttemplates.NewService(templateStore) + if err != nil { + return err + } installation, err := installationFacts(public) if err != nil { return err @@ -318,7 +325,8 @@ func run() error { Installation: installation, InstallationBindings: executionStore, Projects: executionStore, ModelProviders: executionStore, Vaults: vaultService, VaultsReader: vaultStore, - Skills: executionStore, EnvironmentTemplates: executionStore, + Skills: executionStore, + EnvironmentTemplates: environmentTemplates, EnvironmentTemplatesReader: templateStore, Files: fileService, FilesReader: fileStore, Agents: agentService, AgentsReader: agentStore, Sessions: executionStore, SessionEvents: executionStore, SessionHistory: executionStore, diff --git a/services/core/internal/api/dependencies.go b/services/core/internal/api/dependencies.go index 6f1f3c08..942f3a86 100644 --- a/services/core/internal/api/dependencies.go +++ b/services/core/internal/api/dependencies.go @@ -54,6 +54,8 @@ type Dependencies struct { RuntimeObservations RuntimeObservations RuntimeHistory RuntimeHistory + EnvironmentTemplatesReader EnvironmentTemplatesReader + // Execution is nil when this Core runs without a Runtime gateway, and so // without an execution Worker. Work that needs one then answers 503 // execution_unavailable. @@ -116,8 +118,9 @@ func (d Dependencies) validate() error { field{"Vaults", d.Vaults}, field{"VaultsReader", d.VaultsReader}, field{"ModelProviders", d.ModelProviders}, field{"Skills", d.Skills}, field{"Files", d.Files}, field{"FilesReader", d.FilesReader}, + field{"EnvironmentTemplates", d.EnvironmentTemplates}, field{"EnvironmentTemplatesReader", d.EnvironmentTemplatesReader}, field{"Agents", d.Agents}, field{"AgentsReader", d.AgentsReader}, - field{"EnvironmentTemplates", d.EnvironmentTemplates}, field{"Sessions", d.Sessions}, + field{"Sessions", d.Sessions}, field{"SessionEvents", d.SessionEvents}, field{"SessionHistory", d.SessionHistory}, field{"Subagents", d.Subagents}, field{"Artifacts", d.Artifacts}, field{"SessionAdmin", d.SessionAdmin}, field{"Environments", d.Environments}, field{"ExecutorConnections", d.ExecutorConnections}, field{"Admin", d.Admin}, field{"AdminAudit", d.AdminAudit}, field{"WriteAudit", d.WriteAudit}, diff --git a/services/core/internal/api/dependencies_test.go b/services/core/internal/api/dependencies_test.go index 8ff1a324..45a53e55 100644 --- a/services/core/internal/api/dependencies_test.go +++ b/services/core/internal/api/dependencies_test.go @@ -46,6 +46,8 @@ type testFakes struct { deployment *fakeDeployment deploymentChanges *fakeDeploymentChanges configurationDiscovery *fakeConfigurationDiscovery + + environmentTemplatesReader *fakeEnvironmentTemplatesReader } // testDependencies returns Dependencies in which every area is a strict fake. @@ -58,7 +60,8 @@ func testDependencies(t testing.TB) (Dependencies, *testFakes) { f := &testFakes{ projects: &fakeProjects{t: t}, modelProviders: &fakeModelProviders{t: t}, vaults: &fakeVaults{t: t}, vaultsReader: &fakeVaultsReader{t: t}, - skills: &fakeSkills{t: t}, environmentTemplates: &fakeEnvironmentTemplates{t: t}, + skills: &fakeSkills{t: t}, + environmentTemplates: &fakeEnvironmentTemplates{t: t}, environmentTemplatesReader: &fakeEnvironmentTemplatesReader{t: t}, files: &fakeFiles{t: t}, filesReader: &fakeFilesReader{t: t}, agents: &fakeAgents{t: t}, agentsReader: &fakeAgentsReader{t: t}, sessions: &fakeSessions{t: t}, sessionEvents: &fakeSessionEvents{t: t}, @@ -74,8 +77,9 @@ func testDependencies(t testing.TB) (Dependencies, *testFakes) { Projects: f.projects, ModelProviders: f.modelProviders, Skills: f.skills, Vaults: f.vaults, VaultsReader: f.vaultsReader, Files: f.files, FilesReader: f.filesReader, + EnvironmentTemplates: f.environmentTemplates, EnvironmentTemplatesReader: f.environmentTemplatesReader, Agents: f.agents, AgentsReader: f.agentsReader, - EnvironmentTemplates: f.environmentTemplates, Sessions: f.sessions, SessionEvents: f.sessionEvents, + Sessions: f.sessions, SessionEvents: f.sessionEvents, SessionHistory: f.sessionHistory, Subagents: f.subagents, Artifacts: f.artifacts, SessionAdmin: f.sessionAdmin, Environments: f.environments, ExecutorConnections: f.executorConnections, Admin: f.admin, AdminAudit: f.adminAudit, WriteAudit: f.writeAudit, Metrics: f.metrics, RuntimeObservations: f.runtimeObservations, RuntimeHistory: f.runtimeHistory, diff --git a/services/core/internal/api/environment_network_test.go b/services/core/internal/api/environment_network_test.go index 71b0a722..09da6b01 100644 --- a/services/core/internal/api/environment_network_test.go +++ b/services/core/internal/api/environment_network_test.go @@ -5,6 +5,7 @@ import ( "reflect" "testing" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/environmenttemplates" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/store" ) @@ -15,7 +16,7 @@ func TestRestrictedNetworkPublicMetadataPreservesInput(t *testing.T) { if err != nil || !in.SetNetwork || !reflect.DeepEqual(in.AllowedDomains, domains) { t.Fatal("template input changed", in, err) } - response := templateResponse(store.EnvironmentTemplate{NetworkAccess: in.NetworkAccess, AllowedDomains: in.AllowedDomains}) + response := templateResponse(environmenttemplates.Template{NetworkAccess: in.NetworkAccess, AllowedDomains: in.AllowedDomains}) if response.Network.Access != "restricted" || !reflect.DeepEqual(response.Network.AllowedDomains, domains) { t.Fatal("template response changed", response.Network) } @@ -46,8 +47,7 @@ func TestRestrictedNetworkPublicMetadataPreservesInput(t *testing.T) { } func TestTemplateNetworkOverridesOnlyNarrowAndRetainIntent(t *testing.T) { - lookup := &templateLookupStore{network: "restricted", domains: []string{"Example.com", "api.example.com", "example.com"}} - h := templateHandler(t, lookup.ResolveEnvironmentTemplate) + lookup := &templateLookup{network: "restricted", domains: []string{"Example.com", "api.example.com", "example.com"}} for _, test := range []struct { name, override string want []string @@ -75,7 +75,7 @@ func TestTemplateNetworkOverridesOnlyNarrowAndRetainIntent(t *testing.T) { if err != nil { t.Fatal(err) } - err = h.resolveTemplateEnvironment(t.Context(), "tenant", &input) + err = applyTemplateEnvironment(&input, lookup.resolved()) if test.invalid { if err == nil { t.Fatal("template authority widened") diff --git a/services/core/internal/api/environment_plugins_test.go b/services/core/internal/api/environment_plugins_test.go index 106b12c6..a1cb1772 100644 --- a/services/core/internal/api/environment_plugins_test.go +++ b/services/core/internal/api/environment_plugins_test.go @@ -44,7 +44,7 @@ func TestPluginsSharedParsingConfidentialMetadataAndOverrides(t *testing.T) { plugin := pluginInput(t) raw := []byte(`{"plugins":[` + string(plugin) + `],"capability_directories":["/workspace/generated"]}`) template, err := decodeTemplateInput(raw) - if err != nil || !template.SetPlugins || !template.SetDirectories || len(template.Initialization.Plugins) != 1 { + if err != nil || !template.SetPlugins || !template.SetDirectories || len(template.Setup.Plugins) != 1 { t.Fatal("template", err) } var decoded decodedSessionRequest @@ -55,7 +55,7 @@ func TestPluginsSharedParsingConfidentialMetadataAndOverrides(t *testing.T) { if err != nil { t.Fatal(err) } - if !reflect.DeepEqual(input.initialization.Plugins, template.Initialization.Plugins) { + if !reflect.DeepEqual(input.initialization.Plugins, template.Setup.Plugins) { t.Fatal("different installation inputs") } cfg, err := resolve(input, "tenant", "key", nil) @@ -79,8 +79,7 @@ func TestPluginsSharedParsingConfidentialMetadataAndOverrides(t *testing.T) { if response, err := hostedSessionEnvironment(env); err != nil || len(*response.Plugins) != 1 || len(*response.CapabilityDirectories) != 1 { t.Fatal("session response", err) } - lookup := &templateLookupStore{network: "enabled", plugins: template.Initialization.Plugins, directories: template.Initialization.CapabilityDirectories} - h := templateHandler(t, lookup.ResolveEnvironmentTemplate) + lookup := &templateLookup{network: "enabled", plugins: template.Setup.Plugins, directories: template.Setup.CapabilityDirectories} for _, override := range []string{"", `,"plugins":null,"capability_directories":null`, `,"plugins":[],"capability_directories":[]`} { if err = json.Unmarshal([]byte(`{"agent":{"model":"test"},"environment":{"type":"openai_hosted","environment_template_id":"template"`+override+`}}`), &decoded); err != nil { t.Fatal(err) @@ -93,7 +92,7 @@ func TestPluginsSharedParsingConfidentialMetadataAndOverrides(t *testing.T) { if err != nil || !bytes.Contains(intent, []byte("template")) { t.Fatal("intent", err) } - if err = h.resolveTemplateEnvironment(t.Context(), "tenant", &in); err != nil { + if err = applyTemplateEnvironment(&in, lookup.resolved()); err != nil { t.Fatal(err) } want := 1 diff --git a/services/core/internal/api/environment_preparation_input_test.go b/services/core/internal/api/environment_preparation_input_test.go index 80f247dc..8904e087 100644 --- a/services/core/internal/api/environment_preparation_input_test.go +++ b/services/core/internal/api/environment_preparation_input_test.go @@ -82,9 +82,9 @@ func TestPreparationTemplateSharedAcrossPlacements(t *testing.T) { if err != nil { t.Fatal(err) } - h := templateHandler(t, compositionFixture().ResolveEnvironmentTemplate) + template := compositionFixture() for _, input := range []*sessionRequest{&hosted, &own} { - if err := h.resolveTemplateEnvironment(t.Context(), "tenant", input); err != nil { + if err := applyTemplateEnvironment(input, *template); err != nil { t.Fatal(err) } } diff --git a/services/core/internal/api/environment_setup_test.go b/services/core/internal/api/environment_setup_test.go index 46740e3a..6d3d3426 100644 --- a/services/core/internal/api/environment_setup_test.go +++ b/services/core/internal/api/environment_setup_test.go @@ -9,7 +9,7 @@ import ( func TestEnvironmentSetupSharedParsingAndConfidentialSnapshot(t *testing.T) { raw := []byte(`{"env":{"TOKEN":"private-env-canary","QUOTED":"'\n$(false)"},"packages":{"npm":["is-number@7.0.0"],"python":["packaging==26.0"]},"setup_commands":[{"command":"printf private-command-canary > result","cwd":null},{"command":"pwd","cwd":"/workspace/sub"}]}`) input, err := decodeTemplateInput(raw) - if err != nil || !input.SetEnv || !input.SetSetup || !input.SetPackages || len(input.Initialization.Commands) != 2 { + if err != nil || !input.SetEnv || !input.SetCommands || !input.SetPackages || len(input.Setup.Commands) != 2 { t.Fatal("template setup input", err) } var request decodedSessionRequest @@ -24,7 +24,7 @@ func TestEnvironmentSetupSharedParsingAndConfidentialSnapshot(t *testing.T) { if err != nil || bytes.Contains(configuration, []byte("canary")) || !bytes.Contains(configuration, []byte("is-number@7.0.0")) { t.Fatal("confidential input in ordinary configuration", err) } - if decoded.initialization.Env["TOKEN"] != input.Initialization.Env["TOKEN"] || len(decoded.initialization.Commands) != 2 { + if decoded.initialization.Env["TOKEN"] != input.Setup.Env["TOKEN"] || len(decoded.initialization.Commands) != 2 { t.Fatal("inline and template parsing diverged") } for _, invalid := range []string{`{"env":{"OPENAI_API_KEY":"x"}}`, `{"env":{"CODEX_HOME":"x"}}`, `{"env":{"OAC_RUNTIME_HOME":"x"}}`, `{"env":{"BAD-NAME":"x"}}`, `{"env":{"VALUE":null}}`, `{"setup_commands":[null]}`, `{"setup_commands":[{}]}`, `{"setup_commands":[{"command":null}]}`, `{"setup_commands":[{"command":"pwd","cwd":""}]}`, `{"packages":{"python":[null]}}`, `{"packages":{"npm":["--ignore-scripts"]}}`} { @@ -33,7 +33,7 @@ func TestEnvironmentSetupSharedParsingAndConfidentialSnapshot(t *testing.T) { } } cleared, err := decodeTemplateInput([]byte(`{"env":null,"setup_commands":null,"packages":null}`)) - if err != nil || !cleared.Initialization.Empty() || !cleared.SetEnv || !cleared.SetSetup || !cleared.SetPackages { + if err != nil || !cleared.Setup.Empty() || !cleared.SetEnv || !cleared.SetCommands || !cleared.SetPackages { t.Fatal("nullable replacements", err) } } diff --git a/services/core/internal/api/environment_skill_selectors_test.go b/services/core/internal/api/environment_skill_selectors_test.go index d12964a4..0db09da7 100644 --- a/services/core/internal/api/environment_skill_selectors_test.go +++ b/services/core/internal/api/environment_skill_selectors_test.go @@ -6,6 +6,7 @@ import ( "testing" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/environmentconfig" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/environmenttemplates" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/store" ) @@ -23,14 +24,14 @@ func TestSkillReferenceNullableSelectorAdmissionAndTemplateProjection(t *testing t.Run(test.name, func(t *testing.T) { skills := `[{"type":"skill_reference","skill_id":"skill-owned"` + test.field + `}]` template, err := decodeTemplateInput([]byte(`{"skills":` + skills + `}`)) - if err != nil || !template.SetSkills || len(template.Initialization.Skills) != 1 { + if err != nil || !template.SetSkills || len(template.Setup.Skills) != 1 { t.Fatalf("template admission: %+v %v", template, err) } 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]) + if !reflect.DeepEqual(template.Setup.Skills[0], want) { + t.Fatalf("unresolved selector changed: %+v", template.Setup.Skills[0]) } - public := templateResponse(store.EnvironmentTemplate{Skills: template.Initialization.SkillMetadata()}) + public := templateResponse(environmenttemplates.Template{Skills: template.Setup.SkillMetadata()}) var reference map[string]any if len(public.Skills) != 1 || json.Unmarshal(public.Skills[0], &reference) != nil { t.Fatalf("template projection: %+v", public.Skills) @@ -45,7 +46,7 @@ func TestSkillReferenceNullableSelectorAdmissionAndTemplateProjection(t *testing t.Fatal(err) } input, err := request.validated() - if err != nil || !reflect.DeepEqual(input.initialization.Skills, template.Initialization.Skills) { + if err != nil || !reflect.DeepEqual(input.initialization.Skills, template.Setup.Skills) { t.Fatalf("Session admission differs from Template: %+v %v", input.initialization.Skills, err) } } diff --git a/services/core/internal/api/environment_skills_test.go b/services/core/internal/api/environment_skills_test.go index 1f03471f..dfed2b36 100644 --- a/services/core/internal/api/environment_skills_test.go +++ b/services/core/internal/api/environment_skills_test.go @@ -32,8 +32,7 @@ func skillInput(t *testing.T, body string) json.RawMessage { } func TestSkillReferenceParsingInheritanceAndReplacement(t *testing.T) { - lookup := &templateLookupStore{network: "enabled", skills: []environmentconfig.Skill{{Metadata: environmentconfig.SkillMetadata{Type: "skill_reference", SkillID: "skill-template", Version: "latest"}}}} - h := templateHandler(t, lookup.ResolveEnvironmentTemplate) + lookup := &templateLookup{network: "enabled", skills: []environmentconfig.Skill{{Metadata: environmentconfig.SkillMetadata{Type: "skill_reference", SkillID: "skill-template", Version: "latest"}}}} for _, fields := range []string{"", `,"skills":[]`, `,"skills":[{"type":"skill_reference","skill_id":"skill-override","version":"2"}]`} { var decoded decodedSessionRequest if err := json.Unmarshal([]byte(`{"agent":{"model":"test"},"environment":{"type":"openai_hosted","environment_template_id":"template"`+fields+`}}`), &decoded); err != nil { @@ -47,7 +46,7 @@ func TestSkillReferenceParsingInheritanceAndReplacement(t *testing.T) { if err != nil || len(intent) == 0 { t.Fatal("missing unresolved retry intent", err) } - if err = h.resolveTemplateEnvironment(t.Context(), "tenant", &input); err != nil { + if err = applyTemplateEnvironment(&input, lookup.resolved()); err != nil { t.Fatal(err) } switch fields { @@ -91,7 +90,7 @@ func TestInlineSkillsSharedParsingSnapshotAndIntent(t *testing.T) { skill := skillInput(t, "private-skill-canary") raw := append(append([]byte(`{"skills":[`), skill...), []byte(`]}`)...) template, err := decodeTemplateInput(raw) - if err != nil || !template.SetSkills || len(template.Initialization.Skills) != 1 { + if err != nil || !template.SetSkills || len(template.Setup.Skills) != 1 { t.Fatal("template", err) } environment := append([]byte(`{"type":"openai_hosted",`), raw[1:]...) @@ -107,7 +106,7 @@ func TestInlineSkillsSharedParsingSnapshotAndIntent(t *testing.T) { return input } inline := decode(`"agent":{"model":"test"}`, environment) - if !bytes.Equal(inline.initialization.Skills[0].Archive, template.Initialization.Skills[0].Archive) { + if !bytes.Equal(inline.initialization.Skills[0].Archive, template.Setup.Skills[0].Archive) { t.Fatal("inline/template differ") } configuration, err := resolve(inline, "tenant", "key", nil) @@ -129,7 +128,7 @@ func TestInlineSkillsSharedParsingSnapshotAndIntent(t *testing.T) { } for _, clearing := range []string{`{"skills":null}`, `{"skills":[]}`} { input, err := decodeTemplateInput([]byte(clearing)) - if err != nil || !input.SetSkills || !input.Initialization.Empty() { + if err != nil || !input.SetSkills || !input.Setup.Empty() { t.Fatal("clear", err) } } diff --git a/services/core/internal/api/environment_templates.go b/services/core/internal/api/environment_templates.go index b6780e23..7c97f5d1 100644 --- a/services/core/internal/api/environment_templates.go +++ b/services/core/internal/api/environment_templates.go @@ -4,48 +4,50 @@ import ( "context" "encoding/json" "net/http" - "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/MiniMax-AI/OpenAgentCore/services/core/internal/environmenttemplates" "github.com/go-chi/chi/v5" ) -// EnvironmentTemplates manages Environment Templates. ResolveEnvironmentTemplate -// reads a Template with its initial files for Session creation. +// EnvironmentTemplates runs the Environment Template write use cases. type EnvironmentTemplates interface { - 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) - DeleteEnvironmentTemplate(context.Context, string, string) (string, error) - ListEnvironmentTemplates(context.Context, string, string, int, bool) (store.EnvironmentTemplatePage, error) + Create(context.Context, environmenttemplates.CreateCommand) (environmenttemplates.Template, error) + Update(context.Context, environmenttemplates.UpdateCommand) (environmenttemplates.Template, error) + Delete(context.Context, environmenttemplates.DeleteCommand) (string, error) } -func decodeTemplateInput(raw []byte) (store.EnvironmentTemplateInput, error) { +// EnvironmentTemplatesReader reads Environment Templates. Resolve also returns +// the decrypted configuration that Session creation composes. +type EnvironmentTemplatesReader interface { + Get(ctx context.Context, tenantID, templateID string) (environmenttemplates.Template, error) + List(ctx context.Context, tenantID string, query environmenttemplates.ListQuery) (environmenttemplates.Page, error) + Resolve(ctx context.Context, tenantID, templateID string) (environmenttemplates.Resolved, error) +} + +func decodeTemplateInput(raw []byte) (environmenttemplates.Input, error) { var fields map[string]json.RawMessage if decodeInputObject(raw, &fields, "name", "network", "capability_directories", "env", "files", "packages", "plugins", "skills", "setup_commands") != nil { - return store.EnvironmentTemplateInput{}, store.ErrInvalidInput + return environmenttemplates.Input{}, environmenttemplates.ErrInvalidInput } - in := store.EnvironmentTemplateInput{} + in := environmenttemplates.Input{} if value, supplied := fields["name"]; supplied { in.SetName = true - if json.Unmarshal(value, &in.Name) != nil || (in.Name != nil && (!utf8.ValidString(*in.Name) || utf8.RuneCountInString(*in.Name) < 1 || utf8.RuneCountInString(*in.Name) > 256)) { - return in, store.ErrInvalidInput + if json.Unmarshal(value, &in.Name) != nil || environmenttemplates.ValidateName(in.Name) != nil { + return in, environmenttemplates.ErrInvalidInput } } delete(fields, "name") _, in.SetNetwork = fields["network"] _, in.SetFiles = fields["files"] _, in.SetEnv = fields["env"] - _, in.SetSetup = fields["setup_commands"] + _, in.SetCommands = fields["setup_commands"] _, in.SetPackages = fields["packages"] _, in.SetSkills = fields["skills"] _, in.SetPlugins = fields["plugins"] _, in.SetDirectories = fields["capability_directories"] var setupErr error - in.Initialization, setupErr = decodeEnvironmentSetup(fields) + in.Setup, setupErr = decodeEnvironmentSetup(fields) if setupErr != nil { return in, setupErr } @@ -68,14 +70,14 @@ func decodeTemplateInput(raw []byte) (store.EnvironmentTemplateInput, error) { return in, nil } -func templateResponse(t store.EnvironmentTemplate) v1.EnvironmentTemplate { +func templateResponse(t environmenttemplates.Template) v1.EnvironmentTemplate { return v1.EnvironmentTemplate{ID: t.ID, Object: "agent.environment.template", Name: t.Name, CreatedAt: t.CreatedAt.Unix(), UpdatedAt: t.UpdatedAt.Unix(), CapabilityDirectories: append([]string{}, t.CapabilityDirectories...), Network: v1.EnvironmentNetwork{Access: t.NetworkAccess, AllowedDomains: append([]string{}, t.AllowedDomains...)}, Packages: packageMetadata(&t.Packages), Files: templateFileResponse(t.Files), Plugins: pluginResponse(t.Plugins), Skills: skillResponse(t.Skills)} } -func readTemplateInput(w http.ResponseWriter, r *http.Request) (store.EnvironmentTemplateInput, bool) { +func readTemplateInput(w http.ResponseWriter, r *http.Request) (environmenttemplates.Input, bool) { raw, ok := readJSONObjectLimit(w, r, 16*1024*1024, "Request exceeds 16 MiB.") if !ok { - return store.EnvironmentTemplateInput{}, false + return environmenttemplates.Input{}, false } in, err := decodeTemplateInput(raw) if writeFieldError(w, err) { @@ -104,9 +106,9 @@ func (h *Handler) createEnvironmentTemplate(w http.ResponseWriter, r *http.Reque if !ok { return } - value, err := h.EnvironmentTemplates.CreateEnvironmentTemplate(r.Context(), tenantID(r), in) + value, err := h.EnvironmentTemplates.Create(r.Context(), environmenttemplates.CreateCommand{TenantID: tenantID(r), Input: in}) if err != nil { - writeStoreError(w, r, err) + writeEnvironmentTemplatesError(w, r, err) return } writeJSON(w, http.StatusCreated, templateResponse(value)) @@ -123,9 +125,9 @@ func (h *Handler) createEnvironmentTemplate(w http.ResponseWriter, r *http.Reque // @Failure 400,401,404,500 {object} v1.ErrorResponse // @Router /agents/environments/templates/{environment_template_id} [get] func (h *Handler) getEnvironmentTemplate(w http.ResponseWriter, r *http.Request) { - value, err := h.EnvironmentTemplates.GetEnvironmentTemplate(r.Context(), tenantID(r), chi.URLParam(r, "environment_template_id")) + value, err := h.EnvironmentTemplatesReader.Get(r.Context(), tenantID(r), chi.URLParam(r, "environment_template_id")) if err != nil { - writeStoreError(w, r, err) + writeEnvironmentTemplatesError(w, r, err) return } writeJSON(w, http.StatusOK, templateResponse(value)) @@ -148,9 +150,9 @@ func (h *Handler) updateEnvironmentTemplate(w http.ResponseWriter, r *http.Reque if !ok { return } - value, err := h.EnvironmentTemplates.UpdateEnvironmentTemplate(r.Context(), tenantID(r), chi.URLParam(r, "environment_template_id"), in) + value, err := h.EnvironmentTemplates.Update(r.Context(), environmenttemplates.UpdateCommand{TenantID: tenantID(r), TemplateID: chi.URLParam(r, "environment_template_id"), Input: in}) if err != nil { - writeStoreError(w, r, err) + writeEnvironmentTemplatesError(w, r, err) return } writeJSON(w, http.StatusOK, templateResponse(value)) @@ -167,9 +169,9 @@ func (h *Handler) updateEnvironmentTemplate(w http.ResponseWriter, r *http.Reque // @Failure 400,401,404,500 {object} v1.ErrorResponse // @Router /agents/environments/templates/{environment_template_id} [delete] func (h *Handler) deleteEnvironmentTemplate(w http.ResponseWriter, r *http.Request) { - id, err := h.EnvironmentTemplates.DeleteEnvironmentTemplate(r.Context(), tenantID(r), chi.URLParam(r, "environment_template_id")) + id, err := h.EnvironmentTemplates.Delete(r.Context(), environmenttemplates.DeleteCommand{TenantID: tenantID(r), TemplateID: chi.URLParam(r, "environment_template_id")}) if err != nil { - writeStoreError(w, r, err) + writeEnvironmentTemplatesError(w, r, err) return } writeJSON(w, http.StatusOK, v1.EnvironmentTemplateDeleted{ID: id, Object: "agent.environment.template.deleted", Deleted: true}) @@ -192,9 +194,9 @@ func (h *Handler) listEnvironmentTemplates(w http.ResponseWriter, r *http.Reques if !ok { return } - page, err := h.EnvironmentTemplates.ListEnvironmentTemplates(r.Context(), tenantID(r), options.after, options.limit, options.ascending) + page, err := h.EnvironmentTemplatesReader.List(r.Context(), tenantID(r), environmenttemplates.ListQuery{After: options.after, Limit: options.limit, Ascending: options.ascending}) if err != nil { - writeStoreError(w, r, err) + writeEnvironmentTemplatesError(w, r, err) return } response := v1.EnvironmentTemplateList{Object: "list", Data: make([]v1.EnvironmentTemplate, 0, len(page.Templates)), HasMore: page.HasMore} diff --git a/services/core/internal/api/environment_templates_test.go b/services/core/internal/api/environment_templates_test.go index d4a790d2..b684de30 100644 --- a/services/core/internal/api/environment_templates_test.go +++ b/services/core/internal/api/environment_templates_test.go @@ -3,15 +3,40 @@ package api import ( "context" "encoding/json" + "errors" + "fmt" "net/http" "net/http/httptest" + "reflect" "strings" "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/store" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/environmenttemplates" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/textvalue" ) +func TestEnvironmentTemplatesErrors(t *testing.T) { + for _, test := range []struct { + err error + status int + body string + }{ + {environmenttemplates.ErrNotFound, 404, "not_found_error"}, + {fmt.Errorf("create: %w", environmenttemplates.ErrInvalidInput), 400, invalidInputMessage}, + {textvalue.ErrUnstorable, 400, unstorableTextMessage}, + {credentialcrypto.ErrUnavailable, 503, "credential_storage_unavailable"}, + {errors.New("template-canary"), 500, "internal_error"}, + } { + response := httptest.NewRecorder() + writeEnvironmentTemplatesError(response, httptest.NewRequest(http.MethodPost, "/v1/agents/environments/templates", nil), test.err) + if response.Code != test.status || !strings.Contains(response.Body.String(), test.body) || strings.Contains(response.Body.String(), "canary") { + t.Errorf("%v: %d %s", test.err, response.Code, response.Body) + } + } +} + func TestTemplateConfigurationRejectsUnqualifiedInputs(t *testing.T) { for _, raw := range []string{`{"network":{"access":"restricted","allowed_domains":["Example.com","example.com"]}}`, `{}`, `{"packages":{}}`, `{"packages":{"npm":null}}`, `{"name":null,"network":null}`, `{"name":"保存","network":{"access":"disabled"},"env":{},"files":[],"setup_commands":[],"packages":{"npm":null}}`} { if _, err := decodeTemplateInput([]byte(raw)); err != nil { @@ -35,31 +60,43 @@ func TestTemplateConfigurationRejectsUnqualifiedInputs(t *testing.T) { } } -type templateLookupStore struct { +// templateLookup is a resolved Template with the given network policy and +// installations. +type templateLookup struct { network string domains []string - tenant string skills []environmentconfig.Skill plugins []environmentconfig.Plugin directories []string } -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: environmentconfig.Setup{Skills: s.skills, Plugins: s.plugins, CapabilityDirectories: s.directories}}, nil, nil +func (l *templateLookup) resolved() environmenttemplates.Resolved { + return environmenttemplates.Resolved{Template: environmenttemplates.Template{ID: "saved", NetworkAccess: l.network, AllowedDomains: l.domains}, Setup: environmentconfig.Setup{Skills: l.skills, Plugins: l.plugins, CapabilityDirectories: l.directories}} } -// templateHandler serves Environment template lookups from resolve. -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 - return Handler{Dependencies: deps} +// Session creation resolves the Template for the caller's tenant, and a +// Template it cannot resolve answers with the Template's error. +func TestSessionCreationResolvesTemplateForTenant(t *testing.T) { + var resolved []string + h, _, tenant := testHandler(t, func(_ *Dependencies, f *testFakes) { + f.environmentTemplatesReader.resolve = func(_ context.Context, tenant, id string) (environmenttemplates.Resolved, error) { + resolved = append(resolved, tenant, id) + return environmenttemplates.Resolved{}, environmenttemplates.ErrNotFound + } + }) + req := httptest.NewRequest(http.MethodPost, "/v1/agents/sessions", strings.NewReader(`{"agent":{"model":"test"},"environment":{"type":"openai_hosted","environment_template_id":"saved"},"input":"Start."}`)) + req.Header.Set("Authorization", "Bearer test-api-key") + req.Header.Set("OpenAI-Beta", "agents=v1") + req.Header.Set("Content-Type", "application/json") + response := httptest.NewRecorder() + h.ServeHTTP(response, req) + if response.Code != http.StatusNotFound || !strings.Contains(response.Body.String(), `"not_found_error"`) || !reflect.DeepEqual(resolved, []string{tenant, "saved"}) { + t.Fatal("missing Template", response.Code, response.Body.String(), resolved) + } } func TestTemplateResolutionAndCreationIntent(t *testing.T) { - lookup := &templateLookupStore{network: "disabled"} - h := templateHandler(t, lookup.ResolveEnvironmentTemplate) + lookup := &templateLookup{network: "disabled"} request := func(raw string) sessionRequest { t.Helper() var decoded decodedSessionRequest @@ -77,7 +114,7 @@ func TestTemplateResolutionAndCreationIntent(t *testing.T) { if err != nil || !strings.Contains(string(intent), `"environment_template_id":"saved"`) { t.Fatal("missing caller intent", string(intent), err) } - if err := h.resolveTemplateEnvironment(t.Context(), "tenant-a", &inherited); err != nil || inherited.Environment.Network.Access != "disabled" || lookup.tenant != "tenant-a" { + if err := applyTemplateEnvironment(&inherited, lookup.resolved()); err != nil || inherited.Environment.Network.Access != "disabled" { t.Fatal("inheritance failed", err) } raw, _ := json.Marshal(inherited.Environment) @@ -89,12 +126,12 @@ func TestTemplateResolutionAndCreationIntent(t *testing.T) { if string(broaderIntent) == string(intent) { t.Fatal("default erased caller override") } - if err := h.resolveTemplateEnvironment(t.Context(), "tenant-a", &broader); err == nil { + if err := applyTemplateEnvironment(&broader, lookup.resolved()); err == nil { t.Fatal("network broadened") } narrower := request(`{"type":"openai_hosted","environment_template_id":"saved","network":{"access":"disabled"}}`) lookup.network = "enabled" - if err := h.resolveTemplateEnvironment(t.Context(), "tenant-a", &narrower); err != nil { + if err := applyTemplateEnvironment(&narrower, lookup.resolved()); err != nil { t.Fatal(err) } for _, raw := range []string{`{"type":"openai_hosted","environment_template_id":null}`, `{"type":"none","environment_template_id":"saved"}`} { diff --git a/services/core/internal/api/errors_environmenttemplates.go b/services/core/internal/api/errors_environmenttemplates.go new file mode 100644 index 00000000..f48c1abb --- /dev/null +++ b/services/core/internal/api/errors_environmenttemplates.go @@ -0,0 +1,24 @@ +package api + +import ( + "errors" + "net/http" + + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/environmenttemplates" +) + +// writeEnvironmentTemplatesError writes the response for an Environment +// Template operation error. +func writeEnvironmentTemplatesError(w http.ResponseWriter, r *http.Request, err error) { + switch { + case errors.Is(err, environmenttemplates.ErrNotFound): + writeError(w, http.StatusNotFound, "not_found_error", "Resource not found.") + case errors.Is(err, environmenttemplates.ErrInvalidInput): + writeError(w, http.StatusBadRequest, "invalid_request", invalidInputMessage) + default: + if writeAuditSourceError(w, r, err) || writeTextValueError(w, r, err) || writeCredentialUnavailableError(w, r, err) { + return + } + writeInternalError(w, r) + } +} diff --git a/services/core/internal/api/fakes_test.go b/services/core/internal/api/fakes_test.go index 759849c6..6ce3bfe2 100644 --- a/services/core/internal/api/fakes_test.go +++ b/services/core/internal/api/fakes_test.go @@ -11,7 +11,7 @@ import ( "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/adminaudit" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/agents" "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/environmenttemplates" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/files" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/identity" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/runtimehistory" @@ -320,55 +320,59 @@ func (f *fakeDeploymentChanges) CancelSandboxReset(a0 context.Context, a1 uint64 } type fakeEnvironmentTemplates struct { - t testing.TB - 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) - deleteEnvironmentTemplate func(context.Context, string, string) (string, error) - listEnvironmentTemplates func(context.Context, string, string, int, bool) (store.EnvironmentTemplatePage, error) + t testing.TB + create func(context.Context, environmenttemplates.CreateCommand) (environmenttemplates.Template, error) + update func(context.Context, environmenttemplates.UpdateCommand) (environmenttemplates.Template, error) + delete func(context.Context, environmenttemplates.DeleteCommand) (string, 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") +func (f *fakeEnvironmentTemplates) Create(a0 context.Context, a1 environmenttemplates.CreateCommand) (environmenttemplates.Template, error) { + if f.create == nil { + unexpectedCall(f.t, "Create") } - return f.resolveEnvironmentTemplate(a0, a1, a2) + return f.create(a0, a1) } -func (f *fakeEnvironmentTemplates) CreateEnvironmentTemplate(a0 context.Context, a1 string, a2 store.EnvironmentTemplateInput) (store.EnvironmentTemplate, error) { - if f.createEnvironmentTemplate == nil { - unexpectedCall(f.t, "CreateEnvironmentTemplate") +func (f *fakeEnvironmentTemplates) Update(a0 context.Context, a1 environmenttemplates.UpdateCommand) (environmenttemplates.Template, error) { + if f.update == nil { + unexpectedCall(f.t, "Update") } - return f.createEnvironmentTemplate(a0, a1, a2) + return f.update(a0, a1) } -func (f *fakeEnvironmentTemplates) GetEnvironmentTemplate(a0 context.Context, a1 string, a2 string) (store.EnvironmentTemplate, error) { - if f.getEnvironmentTemplate == nil { - unexpectedCall(f.t, "GetEnvironmentTemplate") +func (f *fakeEnvironmentTemplates) Delete(a0 context.Context, a1 environmenttemplates.DeleteCommand) (string, error) { + if f.delete == nil { + unexpectedCall(f.t, "Delete") } - return f.getEnvironmentTemplate(a0, a1, a2) + return f.delete(a0, a1) +} + +type fakeEnvironmentTemplatesReader struct { + t testing.TB + get func(context.Context, string, string) (environmenttemplates.Template, error) + list func(context.Context, string, environmenttemplates.ListQuery) (environmenttemplates.Page, error) + resolve func(context.Context, string, string) (environmenttemplates.Resolved, error) } -func (f *fakeEnvironmentTemplates) UpdateEnvironmentTemplate(a0 context.Context, a1 string, a2 string, a3 store.EnvironmentTemplateInput) (store.EnvironmentTemplate, error) { - if f.updateEnvironmentTemplate == nil { - unexpectedCall(f.t, "UpdateEnvironmentTemplate") +func (f *fakeEnvironmentTemplatesReader) Get(a0 context.Context, a1 string, a2 string) (environmenttemplates.Template, error) { + if f.get == nil { + unexpectedCall(f.t, "Get") } - return f.updateEnvironmentTemplate(a0, a1, a2, a3) + return f.get(a0, a1, a2) } -func (f *fakeEnvironmentTemplates) DeleteEnvironmentTemplate(a0 context.Context, a1 string, a2 string) (string, error) { - if f.deleteEnvironmentTemplate == nil { - unexpectedCall(f.t, "DeleteEnvironmentTemplate") +func (f *fakeEnvironmentTemplatesReader) List(a0 context.Context, a1 string, a2 environmenttemplates.ListQuery) (environmenttemplates.Page, error) { + if f.list == nil { + unexpectedCall(f.t, "List") } - return f.deleteEnvironmentTemplate(a0, a1, a2) + return f.list(a0, a1, a2) } -func (f *fakeEnvironmentTemplates) ListEnvironmentTemplates(a0 context.Context, a1 string, a2 string, a3 int, a4 bool) (store.EnvironmentTemplatePage, error) { - if f.listEnvironmentTemplates == nil { - unexpectedCall(f.t, "ListEnvironmentTemplates") +func (f *fakeEnvironmentTemplatesReader) Resolve(a0 context.Context, a1 string, a2 string) (environmenttemplates.Resolved, error) { + if f.resolve == nil { + unexpectedCall(f.t, "Resolve") } - return f.listEnvironmentTemplates(a0, a1, a2, a3, a4) + return f.resolve(a0, a1, a2) } type fakeEnvironmentWorkspaces struct { diff --git a/services/core/internal/api/handler.go b/services/core/internal/api/handler.go index 1f88dd83..08ab2ef0 100644 --- a/services/core/internal/api/handler.go +++ b/services/core/internal/api/handler.go @@ -171,11 +171,20 @@ func (h *Handler) createSession(w http.ResponseWriter, r *http.Request) { if h.recoverSessionCreation(w, r, key, creationRequest, input.Stream) { return } - if err := h.resolveTemplateEnvironment(r.Context(), tenantID(r), &input); err != nil { - if !h.recoverSessionCreation(w, r, key, creationRequest, input.Stream) { - writeStoreError(w, r, err) + if input.templateID != "" { + template, err := h.EnvironmentTemplatesReader.Resolve(r.Context(), tenantID(r), input.templateID) + if err != nil { + if !h.recoverSessionCreation(w, r, key, creationRequest, input.Stream) { + writeEnvironmentTemplatesError(w, r, err) + } + return + } + if err := applyTemplateEnvironment(&input, template); err != nil { + if !h.recoverSessionCreation(w, r, key, creationRequest, input.Stream) { + writeStoreError(w, r, err) + } + return } - return } saved, inheritedProvider, err := h.sessionAgentDefaults(r.Context(), tenantID(r), input) if err != nil { diff --git a/services/core/internal/api/hosted_structured_test.go b/services/core/internal/api/hosted_structured_test.go index 4b4e2ebc..16fe09fe 100644 --- a/services/core/internal/api/hosted_structured_test.go +++ b/services/core/internal/api/hosted_structured_test.go @@ -18,9 +18,8 @@ func TestHostedStructuredConfigurationQualification(t *testing.T) { if err != nil { t.Fatal(err) } - lookup := &templateLookupStore{network: "enabled", skills: template.Initialization.Skills, - plugins: template.Initialization.Plugins, directories: template.Initialization.CapabilityDirectories} - h := templateHandler(t, lookup.ResolveEnvironmentTemplate) + lookup := &templateLookup{network: "enabled", skills: template.Setup.Skills, + plugins: template.Setup.Plugins, directories: template.Setup.CapabilityDirectories} for _, environment := range []string{ `{"type":"openai_hosted",` + fields + `}`, `{"type":"openai_hosted","environment_template_id":"saved"}`, @@ -34,8 +33,10 @@ func TestHostedStructuredConfigurationQualification(t *testing.T) { if err != nil { t.Fatal(err) } - if err := h.resolveTemplateEnvironment(t.Context(), "tenant", &input); err != nil { - t.Fatal(err) + if input.templateID != "" { + if err := applyTemplateEnvironment(&input, lookup.resolved()); err != nil { + t.Fatal(err) + } } configuration, err := resolve(input, "tenant", "key", nil) if err != nil { diff --git a/services/core/internal/api/resource_creation_test.go b/services/core/internal/api/resource_creation_test.go index c4412a9c..896fd533 100644 --- a/services/core/internal/api/resource_creation_test.go +++ b/services/core/internal/api/resource_creation_test.go @@ -7,7 +7,7 @@ import ( "testing" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/agents" - "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/store" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/environmenttemplates" "github.com/google/uuid" ) @@ -17,14 +17,14 @@ func (*resourceCreationStore) CreateAgent(_ context.Context, command agents.Crea return agents.Agent{ID: uuid.NewString(), TenantID: command.TenantID, Configuration: command.Configuration, Metadata: command.Metadata}, nil } -func (*resourceCreationStore) CreateEnvironmentTemplate(context.Context, string, store.EnvironmentTemplateInput) (store.EnvironmentTemplate, error) { - return store.EnvironmentTemplate{ID: uuid.NewString(), NetworkAccess: "enabled"}, nil +func (*resourceCreationStore) CreateEnvironmentTemplate(context.Context, environmenttemplates.CreateCommand) (environmenttemplates.Template, error) { + return environmenttemplates.Template{ID: uuid.NewString(), NetworkAccess: "enabled"}, nil } func TestAgentAndTemplateCreationStatus(t *testing.T) { s := &resourceCreationStore{} h, _, _ := testHandler(t, func(_ *Dependencies, f *testFakes) { - f.agents.create, f.environmentTemplates.createEnvironmentTemplate = s.CreateAgent, s.CreateEnvironmentTemplate + f.agents.create, f.environmentTemplates.create = s.CreateAgent, s.CreateEnvironmentTemplate }) for _, tc := range []struct{ path, body, object string }{ {"/v1/agents", `{"model":"resource-model"}`, "agent"}, diff --git a/services/core/internal/api/resource_query_test.go b/services/core/internal/api/resource_query_test.go index e94672e5..b502081b 100644 --- a/services/core/internal/api/resource_query_test.go +++ b/services/core/internal/api/resource_query_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/agents" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/environmenttemplates" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/runtimedevice" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/store" "github.com/google/uuid" @@ -60,23 +61,30 @@ func (s *missingResourceStore) UpdateSessionMetadata(_ context.Context, tenant, return store.Session{}, s.missing(tenant) } -func (s *missingResourceStore) GetEnvironmentTemplate(_ context.Context, tenant, _ string) (store.EnvironmentTemplate, error) { - return store.EnvironmentTemplate{}, s.missing(tenant) +// Environment Template operations report a missing Template with their +// domain's error. +func (s *missingResourceStore) missingTemplate(tenant string) error { + s.tenants = append(s.tenants, tenant) + return environmenttemplates.ErrNotFound +} + +func (s *missingResourceStore) GetEnvironmentTemplate(_ context.Context, tenant, _ string) (environmenttemplates.Template, error) { + return environmenttemplates.Template{}, s.missingTemplate(tenant) } -func (s *missingResourceStore) UpdateEnvironmentTemplate(_ context.Context, tenant, _ string, _ store.EnvironmentTemplateInput) (store.EnvironmentTemplate, error) { - return store.EnvironmentTemplate{}, s.missing(tenant) +func (s *missingResourceStore) UpdateEnvironmentTemplate(_ context.Context, command environmenttemplates.UpdateCommand) (environmenttemplates.Template, error) { + return environmenttemplates.Template{}, s.missingTemplate(command.TenantID) } -func (s *missingResourceStore) DeleteEnvironmentTemplate(_ context.Context, tenant, _ string) (string, error) { - return "", s.missing(tenant) +func (s *missingResourceStore) DeleteEnvironmentTemplate(_ context.Context, command environmenttemplates.DeleteCommand) (string, error) { + return "", s.missingTemplate(command.TenantID) } // wire serves the Agent, Session and Environment template lookups from s. func (s *missingResourceStore) wire(_ *Dependencies, f *testFakes) { f.agentsReader.getAgent, f.agents.delete, f.agents.update = s.GetAgent, s.DeleteAgent, s.UpdateAgent f.sessions.getSession, f.sessions.deleteSession, f.sessions.updateSessionMetadata = s.GetSession, s.DeleteSession, s.UpdateSessionMetadata - f.environmentTemplates.getEnvironmentTemplate, f.environmentTemplates.updateEnvironmentTemplate, f.environmentTemplates.deleteEnvironmentTemplate = s.GetEnvironmentTemplate, s.UpdateEnvironmentTemplate, s.DeleteEnvironmentTemplate + f.environmentTemplatesReader.get, f.environmentTemplates.update, f.environmentTemplates.delete = s.GetEnvironmentTemplate, s.UpdateEnvironmentTemplate, s.DeleteEnvironmentTemplate } // twoTenantHandler authenticates "test-api-key" as the owner and "foreign-key" as diff --git a/services/core/internal/api/session_template.go b/services/core/internal/api/session_template.go index 4ef1e323..7e7edbf7 100644 --- a/services/core/internal/api/session_template.go +++ b/services/core/internal/api/session_template.go @@ -2,7 +2,6 @@ package api import ( "bytes" - "context" "encoding/json" "maps" "slices" @@ -10,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/environmenttemplates" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/store" ) @@ -47,14 +47,12 @@ func decodePreparationTemplate(raw json.RawMessage, extension bool) (*v1.Environ return environment, id, raw, err } -func (h *Handler) resolveTemplateEnvironment(ctx context.Context, tenant string, input *sessionRequest) error { - if input.templateID == "" { - return nil - } - template, files, err := h.EnvironmentTemplates.ResolveEnvironmentTemplate(ctx, tenant, input.templateID) - if err != nil { - return err - } +// applyTemplateEnvironment composes the Session environment from the resolved +// Template and the fields the request supplies. Each non-null request field +// replaces the Template's, env merges key by key and the network may only +// narrow the Template's policy. +func applyTemplateEnvironment(input *sessionRequest, resolved environmenttemplates.Resolved) error { + template, files := resolved.Template, resolved.Files var fields map[string]json.RawMessage if json.Unmarshal(input.templateEnvironment, &fields) != nil { return store.ErrInvalidInput @@ -71,17 +69,17 @@ func (h *Handler) resolveTemplateEnvironment(ctx context.Context, tenant string, } skills := input.initialization.Skills if !templateFieldOverride(fields, "skills") { - skills = template.Initialization.Skills + skills = resolved.Setup.Skills } plugins := input.initialization.Plugins if !templateFieldOverride(fields, "plugins") { - plugins = template.Initialization.Plugins + plugins = resolved.Setup.Plugins } directories := input.initialization.CapabilityDirectories if !templateFieldOverride(fields, "capability_directories") { - directories = template.Initialization.CapabilityDirectories + directories = resolved.Setup.CapabilityDirectories } - setup := template.Initialization + setup := resolved.Setup setup.Env = maps.Clone(setup.Env) if len(input.initialization.Env) > 0 { if setup.Env == nil { diff --git a/services/core/internal/api/session_template_composition_test.go b/services/core/internal/api/session_template_composition_test.go index dfa0e7d8..7fe9e83d 100644 --- a/services/core/internal/api/session_template_composition_test.go +++ b/services/core/internal/api/session_template_composition_test.go @@ -2,7 +2,6 @@ package api import ( "bytes" - "context" "encoding/json" "reflect" "strings" @@ -10,18 +9,9 @@ import ( 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/MiniMax-AI/OpenAgentCore/services/core/internal/environmenttemplates" ) -type compositionTemplateStore struct { - template store.EnvironmentTemplate - files []environmentconfig.InitialFile -} - -func (s *compositionTemplateStore) ResolveEnvironmentTemplate(context.Context, string, string) (store.EnvironmentTemplate, []environmentconfig.InitialFile, error) { - return s.template, s.files, nil -} - func compositionRequest(t *testing.T, fields string) sessionRequest { t.Helper() var request decodedSessionRequest @@ -36,14 +26,15 @@ func compositionRequest(t *testing.T, fields string) sessionRequest { return input } -func compositionFixture() *compositionTemplateStore { - return &compositionTemplateStore{ - template: store.EnvironmentTemplate{NetworkAccess: "enabled", Initialization: environmentconfig.Setup{ +func compositionFixture() *environmenttemplates.Resolved { + return &environmenttemplates.Resolved{ + Template: environmenttemplates.Template{NetworkAccess: "enabled"}, + Setup: environmentconfig.Setup{ Env: map[string]string{"TEMPLATE": "private-template-env", "SHARED": "private-old-value"}, Commands: []environmentconfig.SetupCommand{{Command: "printf private-template-command"}}, Packages: v1.EnvironmentPackages{NPM: []string{"semver@7.7.2"}, Python: []string{"packaging==25.0"}}, - }}, - files: []environmentconfig.InitialFile{{Type: "inline", Path: "/workspace/template", Data: []byte("private-template-bytes")}}, + }, + Files: []environmentconfig.InitialFile{{Type: "inline", Path: "/workspace/template", Data: []byte("private-template-bytes")}}, } } @@ -75,7 +66,7 @@ func TestTemplateInlineCompositionRules(t *testing.T) { } { t.Run(test.name, func(t *testing.T) { lookup, expected := compositionFixture(), compositionFixture() - wantSetup, wantFiles := expected.template.Initialization, expected.files + wantSetup, wantFiles := expected.Setup, expected.Files if test.change != nil { test.change(&wantSetup, &wantFiles) } @@ -84,10 +75,9 @@ func TestTemplateInlineCompositionRules(t *testing.T) { if err != nil { t.Fatal(err) } - beforeTemplate, _ := json.Marshal(lookup.template) - beforeFiles, _ := json.Marshal(lookup.files) - h := templateHandler(t, lookup.ResolveEnvironmentTemplate) - if err := h.resolveTemplateEnvironment(t.Context(), "tenant", &input); err != nil { + beforeTemplate, _ := json.Marshal(lookup.Setup) + beforeFiles, _ := json.Marshal(lookup.Files) + if err := applyTemplateEnvironment(&input, *lookup); err != nil { t.Fatal(err) } // JSON omitempty normalizes nil/empty private slices without erasing public lists. @@ -111,8 +101,8 @@ func TestTemplateInlineCompositionRules(t *testing.T) { } } afterIntent, _ := sessionCreationRequest(input, nil) - afterTemplate, _ := json.Marshal(lookup.template) - afterFiles, _ := json.Marshal(lookup.files) + afterTemplate, _ := json.Marshal(lookup.Setup) + afterFiles, _ := json.Marshal(lookup.Files) if !bytes.Equal(intent, afterIntent) || !bytes.Equal(beforeTemplate, afterTemplate) || !bytes.Equal(beforeFiles, afterFiles) { t.Fatal("composition changed caller intent or template") } @@ -127,10 +117,9 @@ func TestTemplateCompositionDoesNotAliasChangedInputs(t *testing.T) { originalSetup, originalFiles := input.initialization, input.initialFiles beforeInline, _ := json.Marshal(originalSetup) beforeInlineFiles, _ := json.Marshal(originalFiles) - beforeTemplate, _ := json.Marshal(lookup.template) - beforeFiles, _ := json.Marshal(lookup.files) - h := templateHandler(t, lookup.ResolveEnvironmentTemplate) - if err := h.resolveTemplateEnvironment(t.Context(), "tenant", &input); err != nil { + beforeTemplate, _ := json.Marshal(lookup.Setup) + beforeFiles, _ := json.Marshal(lookup.Files) + if err := applyTemplateEnvironment(&input, *lookup); err != nil { t.Fatal(err) } input.initialization.Env["SHARED"] = "changed" @@ -139,8 +128,8 @@ func TestTemplateCompositionDoesNotAliasChangedInputs(t *testing.T) { input.initialFiles[0].Path = "/workspace/changed" afterInline, _ := json.Marshal(originalSetup) afterInlineFiles, _ := json.Marshal(originalFiles) - afterTemplate, _ := json.Marshal(lookup.template) - afterFiles, _ := json.Marshal(lookup.files) + afterTemplate, _ := json.Marshal(lookup.Setup) + afterFiles, _ := json.Marshal(lookup.Files) if !bytes.Equal(beforeInline, afterInline) || !bytes.Equal(beforeInlineFiles, afterInlineFiles) || !bytes.Equal(beforeTemplate, afterTemplate) || !bytes.Equal(beforeFiles, afterFiles) { t.Fatal("composed maps/slices alias caller or template") } @@ -150,18 +139,17 @@ func TestTemplateCompositionDoesNotAliasChangedInputs(t *testing.T) { func TestTemplateCompositionRevalidatesCombinedSetupLimit(t *testing.T) { for _, field := range []string{"env", "packages"} { lookup := compositionFixture() - lookup.template.Initialization.Env = map[string]string{"TEMPLATE": strings.Repeat("a", 300<<10)} + lookup.Setup.Env = map[string]string{"TEMPLATE": strings.Repeat("a", 300<<10)} var override any = map[string]any{"INLINE": strings.Repeat("b", 300<<10)} if field == "packages" { override = map[string]any{"python": []string{strings.Repeat("b", 300<<10)}} } raw, _ := json.Marshal(override) input := compositionRequest(t, `,"`+field+`":`+string(raw)) - if lookup.template.Initialization.Validate() != nil || input.initialization.Validate() != nil { + if lookup.Setup.Validate() != nil || input.initialization.Validate() != nil { t.Fatal("each side must be valid independently") } - h := templateHandler(t, lookup.ResolveEnvironmentTemplate) - if err := h.resolveTemplateEnvironment(t.Context(), "tenant", &input); err == nil { + if err := applyTemplateEnvironment(&input, *lookup); err == nil { t.Fatalf("combined setup limit bypassed for %s", field) } } @@ -191,15 +179,14 @@ func TestTemplateFilesReplacementDoesNotCombineCounts(t *testing.T) { for i := range files { files[i] = environmentconfig.InitialFile{Type: "inline", Path: "/workspace/" + strings.Repeat("a", i+1)} } - lookup.files = files + lookup.Files = files wire := make([]map[string]string, len(files)) for i, file := range files { wire[i] = map[string]string{"type": "inline", "path": file.Path, "data": ""} } raw, _ := json.Marshal(wire) input := compositionRequest(t, `,"files":`+string(raw)) - h := templateHandler(t, lookup.ResolveEnvironmentTemplate) - if err := h.resolveTemplateEnvironment(t.Context(), "tenant", &input); err != nil || len(input.initialFiles) != 30 { + if err := applyTemplateEnvironment(&input, *lookup); err != nil || len(input.initialFiles) != 30 { t.Fatalf("file lists were combined: count=%d err=%v", len(input.initialFiles), err) } } diff --git a/services/core/internal/api/session_template_null_test.go b/services/core/internal/api/session_template_null_test.go index 695789cf..c86e275e 100644 --- a/services/core/internal/api/session_template_null_test.go +++ b/services/core/internal/api/session_template_null_test.go @@ -28,15 +28,14 @@ func TestTemplateNullSelectionRetainsInheritedCapabilitiesAndPolicy(t *testing.T {name: "clear only directories", fields: `,"skills":null,"plugins":null,"capability_directories":[]`, clearDirectories: true}, } { t.Run(test.name, func(t *testing.T) { - lookup := &templateLookupStore{network: "restricted", domains: []string{"example.com"}, skills: template.Initialization.Skills, plugins: template.Initialization.Plugins, directories: template.Initialization.CapabilityDirectories} + lookup := &templateLookup{network: "restricted", domains: []string{"example.com"}, skills: template.Setup.Skills, plugins: template.Setup.Plugins, directories: template.Setup.CapabilityDirectories} input := compositionRequest(t, test.fields) intent, err := sessionCreationRequest(input, nil) if err != nil { t.Fatal(err) } - before, _ := json.Marshal(template.Initialization) - h := templateHandler(t, lookup.ResolveEnvironmentTemplate) - if err := h.resolveTemplateEnvironment(t.Context(), "tenant", &input); err != nil { + before, _ := json.Marshal(template.Setup) + if err := applyTemplateEnvironment(&input, lookup.resolved()); err != nil { t.Fatal(err) } if input.Environment.Network.Access != "restricted" || !reflect.DeepEqual(input.Environment.Network.AllowedDomains, lookup.domains) { @@ -46,21 +45,21 @@ func TestTemplateNullSelectionRetainsInheritedCapabilitiesAndPolicy(t *testing.T if len(input.initialization.Skills) != 0 { t.Fatal("skills not cleared") } - } else if !reflect.DeepEqual(input.initialization.Skills, template.Initialization.Skills) { + } else if !reflect.DeepEqual(input.initialization.Skills, template.Setup.Skills) { t.Fatal("skills not inherited") } if test.clearPlugins { if len(input.initialization.Plugins) != 0 { t.Fatal("plugins not cleared") } - } else if !reflect.DeepEqual(input.initialization.Plugins, template.Initialization.Plugins) { + } else if !reflect.DeepEqual(input.initialization.Plugins, template.Setup.Plugins) { t.Fatal("plugins not inherited") } if test.clearDirectories { if len(input.initialization.CapabilityDirectories) != 0 { t.Fatal("directories not cleared") } - } else if !reflect.DeepEqual(input.initialization.CapabilityDirectories, template.Initialization.CapabilityDirectories) { + } else if !reflect.DeepEqual(input.initialization.CapabilityDirectories, template.Setup.CapabilityDirectories) { t.Fatal("directories not inherited") } if !reflect.DeepEqual(input.Environment.Skills, skillResponse(input.initialization.SkillMetadata())) || !reflect.DeepEqual(input.Environment.Plugins, pluginResponse(input.initialization.PluginMetadata())) || len(input.Environment.CapabilityDirectories) != len(input.initialization.CapabilityDirectories) { @@ -72,7 +71,7 @@ func TestTemplateNullSelectionRetainsInheritedCapabilitiesAndPolicy(t *testing.T t.Fatal("private initialization leaked to public metadata") } } - after, _ := json.Marshal(template.Initialization) + after, _ := json.Marshal(template.Setup) afterIntent, _ := sessionCreationRequest(input, nil) if !bytes.Equal(before, after) || !bytes.Equal(intent, afterIntent) { t.Fatal("selection mutated template or caller intent") @@ -87,9 +86,9 @@ func TestTemplateNullSelectionDoesNotBypassValidation(t *testing.T) { t.Fatal("invalid nonnull override accepted", fields) } } - h := templateHandler(t, (&templateLookupStore{network: "disabled"}).ResolveEnvironmentTemplate) + lookup := &templateLookup{network: "disabled"} input := compositionRequest(t, `,"network":{"access":"enabled"},"skills":null,"plugins":null,"capability_directories":null`) - if err := h.resolveTemplateEnvironment(t.Context(), "tenant", &input); err == nil { + if err := applyTemplateEnvironment(&input, lookup.resolved()); err == nil { t.Fatal("capability null overrides bypassed network narrowing") } } diff --git a/services/core/internal/api/validation_errors_test.go b/services/core/internal/api/validation_errors_test.go index 3b693261..7b929bc0 100644 --- a/services/core/internal/api/validation_errors_test.go +++ b/services/core/internal/api/validation_errors_test.go @@ -12,6 +12,7 @@ import ( v1 "github.com/MiniMax-AI/OpenAgentCore/contracts/agents-api/v1" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/agents" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/environmenttemplates" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/store" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/vaults" "github.com/google/uuid" @@ -49,14 +50,14 @@ func (s *validationStore) CreateSession(_ context.Context, tenant string, input return store.Session{ID: uuid.NewString(), TenantID: tenant, Metadata: input.Metadata, Configuration: input.Configuration}, nil } -func (s *validationStore) CreateEnvironmentTemplate(context.Context, string, store.EnvironmentTemplateInput) (store.EnvironmentTemplate, error) { +func (s *validationStore) CreateEnvironmentTemplate(context.Context, environmenttemplates.CreateCommand) (environmenttemplates.Template, error) { s.writes++ - return store.EnvironmentTemplate{ID: uuid.NewString(), NetworkAccess: "enabled"}, nil + return environmenttemplates.Template{ID: uuid.NewString(), NetworkAccess: "enabled"}, nil } -func (s *validationStore) UpdateEnvironmentTemplate(_ context.Context, _, id string, input store.EnvironmentTemplateInput) (store.EnvironmentTemplate, error) { +func (s *validationStore) UpdateEnvironmentTemplate(_ context.Context, command environmenttemplates.UpdateCommand) (environmenttemplates.Template, error) { s.writes++ - return store.EnvironmentTemplate{ID: id, NetworkAccess: input.NetworkAccess, AllowedDomains: input.AllowedDomains}, nil + return environmenttemplates.Template{ID: command.TemplateID, NetworkAccess: command.Input.NetworkAccess, AllowedDomains: command.Input.AllowedDomains}, nil } // serve takes every write from the handler, and the Worker admits Sessions @@ -67,7 +68,7 @@ func (s *validationStore) serve(d *Dependencies, f *testFakes) { f.agents.create, f.agents.update = s.CreateAgent, s.UpdateAgent f.vaults.createVault = s.CreateVault f.sessions.getSession, f.sessions.updateSessionMetadata = nil, s.UpdateSessionMetadata - f.environmentTemplates.createEnvironmentTemplate, f.environmentTemplates.updateEnvironmentTemplate = s.CreateEnvironmentTemplate, s.UpdateEnvironmentTemplate + f.environmentTemplates.create, f.environmentTemplates.update = s.CreateEnvironmentTemplate, s.UpdateEnvironmentTemplate } func validationHandler(t *testing.T) (http.Handler, *validationStore) { diff --git a/services/core/internal/persistence/postgres/templatepg/audit_test.go b/services/core/internal/persistence/postgres/templatepg/audit_test.go index ac50c78b..c59553c3 100644 --- a/services/core/internal/persistence/postgres/templatepg/audit_test.go +++ b/services/core/internal/persistence/postgres/templatepg/audit_test.go @@ -157,15 +157,15 @@ func TestAdminDeleteAuditCommitsWithTheDeletion(t *testing.T) { if err != nil || id != template.ID { t.Fatal(id, err) } - var credential, actor, project, action, kind, gotID, raw string - if err := f.pool.QueryRow(t.Context(), `SELECT admin_credential_id,actor_label,project_id,action,resource_type,resource_id,to_jsonb(a)::text FROM admin_audit_log a WHERE tenant_id=$1 AND request_id=$2`, tenant, request).Scan(&credential, &actor, &project, &action, &kind, &gotID, &raw); err != nil { + var credential, actor, project, trace, action, kind, gotID, mappings, raw string + if err := f.pool.QueryRow(t.Context(), `SELECT admin_credential_id,actor_label,project_id,trace_id,action,resource_type,resource_id,result_ids::text,to_jsonb(a)::text FROM admin_audit_log a WHERE tenant_id=$1 AND request_id=$2`, tenant, request).Scan(&credential, &actor, &project, &trace, &action, &kind, &gotID, &mappings, &raw); err != nil { t.Fatal(err) } - if credential != "87654321" || actor != "administrator fixture" || project != tenant || action != "delete" || kind != "environment_template" || gotID != id || strings.Contains(raw, "admin-private") { + if credential != "87654321" || actor != "administrator fixture" || project != tenant || trace != "admin-mutation-trace" || action != "delete" || kind != "environment_template" || gotID != id || mappings != "[]" || strings.Contains(raw, "admin-private") { t.Fatal("administrator audit identity differs") } - var operations int - if err := f.pool.QueryRow(t.Context(), `SELECT count(*) FROM write_audit_operations WHERE tenant_id=$1 AND action='delete'`, tenant).Scan(&operations); err != nil || operations != 0 { + var operations, owners int + if err := f.pool.QueryRow(t.Context(), `SELECT (SELECT count(*) FROM write_audit_operations WHERE tenant_id=$1 AND action='delete'),(SELECT count(*) FROM write_audit_owners WHERE tenant_id=$1 AND resource_type='environment_template' AND resource_id=$2)`, tenant, id).Scan(&operations, &owners); err != nil || operations != 0 || owners != 0 { t.Fatal("administrator impersonated public-key provenance", err) } if _, err := f.keyless.Get(t.Context(), tenant, id); !errors.Is(err, environmenttemplates.ErrNotFound) { diff --git a/services/core/internal/persistence/postgres/templatepg/store_test.go b/services/core/internal/persistence/postgres/templatepg/store_test.go index c5c5c1c6..ee5b02b8 100644 --- a/services/core/internal/persistence/postgres/templatepg/store_test.go +++ b/services/core/internal/persistence/postgres/templatepg/store_test.go @@ -370,6 +370,37 @@ func TestMissingKeyIsUnavailable(t *testing.T) { } } +// Stored package metadata naming the removed system manager is rejected on +// every read instead of being silently dropped. +func TestStoredSystemPackagesRejected(t *testing.T) { + f := newFixture(t, pgtest.Open(t)) + ctx := t.Context() + tenant := uuid.NewString() + template := f.create(t, tenant, environmenttemplates.Input{}) + store := func(packages string) { + t.Helper() + if _, err := f.pool.Exec(ctx, "UPDATE environment_templates SET packages=$1 WHERE id=$2", []byte(packages), template.ID); err != nil { + t.Fatal(err) + } + } + for _, value := range []string{`null`, `[]`, `["jq"]`} { + store(`{"npm":[],"python":[],"system":` + value + `}`) + if _, err := f.keyless.Get(ctx, tenant, template.ID); !errors.Is(err, environmenttemplates.ErrInvalidInput) { + t.Fatal("get silently ignored removed system packages", value, err) + } + if _, err := f.keyless.List(ctx, tenant, environmenttemplates.ListQuery{Limit: 1}); !errors.Is(err, environmenttemplates.ErrInvalidInput) { + t.Fatal("list silently ignored removed system packages", value, err) + } + if _, err := f.keyed.Resolve(ctx, tenant, template.ID); !errors.Is(err, environmenttemplates.ErrInvalidInput) { + t.Fatal("resolve silently ignored removed system packages", value, err) + } + } + store(`{"npm":["semver"],"python":["packaging"]}`) + if got, err := f.keyless.Get(ctx, tenant, template.ID); err != nil || !reflect.DeepEqual(got.Packages, v1.EnvironmentPackages{NPM: []string{"semver"}, Python: []string{"packaging"}}) { + t.Fatal("supported stored package managers rejected", got.Packages, err) + } +} + func archive(t *testing.T, files map[string]string) []byte { t.Helper() var buffer bytes.Buffer diff --git a/services/core/internal/sandbox/providers/configuration_flow_test.go b/services/core/internal/sandbox/providers/configuration_flow_test.go index 74f6adfb..7648fc87 100644 --- a/services/core/internal/sandbox/providers/configuration_flow_test.go +++ b/services/core/internal/sandbox/providers/configuration_flow_test.go @@ -106,8 +106,9 @@ func TestAdditionalConfigurationProviderUsesCommonAPIAndStore(t *testing.T) { Engine: "codex", CoreKeys: auth, InstallationBindings: s, Projects: s, ModelProviders: s, Skills: s, Vaults: struct{ api.Vaults }{}, VaultsReader: struct{ api.VaultsReader }{}, Files: struct{ api.Files }{}, FilesReader: struct{ api.FilesReader }{}, + EnvironmentTemplates: struct{ api.EnvironmentTemplates }{}, EnvironmentTemplatesReader: struct{ api.EnvironmentTemplatesReader }{}, Agents: struct{ api.Agents }{}, AgentsReader: struct{ api.AgentsReader }{}, - EnvironmentTemplates: s, Sessions: s, SessionEvents: s, SessionHistory: s, Subagents: s, Artifacts: s, + Sessions: s, SessionEvents: s, SessionHistory: s, Subagents: s, Artifacts: s, SessionAdmin: s, Environments: s, Admin: s, AdminAudit: struct{ api.AdminAudit }{}, WriteAudit: struct{ api.WriteAudit }{}, ExecutorConnections: struct{ api.ExecutorConnections }{}, Metrics: struct{ api.Metrics }{}, RuntimeObservations: struct{ api.RuntimeObservations }{}, RuntimeHistory: struct{ api.RuntimeHistory }{}, diff --git a/services/core/internal/store/admin_delete_audit_test.go b/services/core/internal/store/admin_delete_audit_test.go index 4bf59c64..61f93002 100644 --- a/services/core/internal/store/admin_delete_audit_test.go +++ b/services/core/internal/store/admin_delete_audit_test.go @@ -11,7 +11,6 @@ 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/MiniMax-AI/OpenAgentCore/services/core/internal/sessions" "github.com/google/uuid" "github.com/jackc/pgx/v5" @@ -82,7 +81,7 @@ func assertAdminMutationAudit(t *testing.T, s *Store, tenant, request, action, k if credential != "87654321" || actor != "administrator fixture" || key != expectedKey || trace != "admin-mutation-trace" || gotAction != action || gotKind != kind || gotID != id || mappings != "[]" { t.Fatal("administrator audit identity differs") } - for _, secret := range []string{"admin-private-archive", "admin-private-body", "admin-private-env", "private-agent-canary", "audit-private-token"} { + for _, secret := range []string{"admin-private-archive", "admin-private-body", "private-agent-canary", "audit-private-token"} { if strings.Contains(raw, secret) { t.Fatal("private content entered administrator audit") } @@ -105,8 +104,8 @@ func TestAdminDeleteResourceAuditTransactions(t *testing.T) { s := NewWithCredentialCipher(pool, cipher) rejectAdminAuditInsert(t, s) archive := skillArchive(t, "admin-private-archive") - tables := []string{"agents", "agent_model_execution", "environment_templates", "skills", "skill_versions", "sessions", "turns", "environments", "session_artifacts", "admin_audit_log", "write_audit_operations", "write_audit_owners", "pg_largeobject_metadata", "pg_largeobject"} - for _, name := range []string{"template_delete", "skill_delete", "version_delete", "version_delete_last", "session_delete", "artifact_delete"} { + tables := []string{"agents", "agent_model_execution", "skills", "skill_versions", "sessions", "turns", "environments", "session_artifacts", "admin_audit_log", "write_audit_operations", "write_audit_owners", "pg_largeobject_metadata", "pg_largeobject"} + for _, name := range []string{"skill_delete", "version_delete", "version_delete_last", "session_delete", "artifact_delete"} { t.Run(name, func(t *testing.T) { tenant := uuid.NewString() var mutation resourceAuditMutation @@ -116,15 +115,6 @@ func TestAdminDeleteResourceAuditTransactions(t *testing.T) { tenant, mutation, verifyRestored, removedObjects = prepareAdminHistoryDelete(t, s, name) } else { mutation = prepareResourceAuditMutation(t, s, tenant, name, archive) - if name == "template_delete" { - var id string - 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: 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) - } - } } if _, err := pool.Exec(t.Context(), "INSERT INTO execution_project_scopes(tenant_id,organization_id,project_id) VALUES($1,'admin-delete',$2)", tenant, tenant); err != nil { t.Fatal(err) @@ -202,8 +192,6 @@ func assertAdminDeletedResource(t *testing.T, s *Store, tenant string, mutation t.Helper() var err error switch mutation.kind { - case "environment_template": - _, err = s.GetEnvironmentTemplate(t.Context(), tenant, id) case "skill": _, err = s.GetSkill(t.Context(), tenant, id) case "skill_version": diff --git a/services/core/internal/store/environment_network_test.go b/services/core/internal/store/environment_network_test.go deleted file mode 100644 index e0090fe3..00000000 --- a/services/core/internal/store/environment_network_test.go +++ /dev/null @@ -1,58 +0,0 @@ -package store - -import ( - "errors" - "reflect" - "testing" - - "github.com/google/uuid" -) - -func TestTemplateNetworkPolicyRoundTripAndReplacement(t *testing.T) { - s, _ := testStore(t) - ctx := t.Context() - tenant, foreign := uuid.NewString(), uuid.NewString() - domains := []string{"Example.com", "api.example.com", "Example.com"} - created, err := s.CreateEnvironmentTemplate(ctx, tenant, EnvironmentTemplateInput{SetNetwork: true, NetworkAccess: "restricted", AllowedDomains: domains}) - if err != nil { - t.Fatal(err) - } - check := func(value EnvironmentTemplate, err error) { - t.Helper() - if err != nil || value.NetworkAccess != "restricted" || !reflect.DeepEqual(value.AllowedDomains, domains) { - t.Fatalf("network policy lost: %#v, %v", value, err) - } - } - check(created, nil) - check(s.GetEnvironmentTemplate(ctx, tenant, created.ID)) - resolved, _, err := s.ResolveEnvironmentTemplate(ctx, tenant, created.ID) - check(resolved, err) - page, err := s.ListEnvironmentTemplates(ctx, tenant, "", 1, true) - if err != nil || len(page.Templates) != 1 { - t.Fatal(page, err) - } - check(page.Templates[0], nil) - name := "renamed" - check(s.UpdateEnvironmentTemplate(ctx, tenant, created.ID, EnvironmentTemplateInput{SetName: true, Name: &name})) - if _, _, err := s.ResolveEnvironmentTemplate(ctx, foreign, created.ID); !errors.Is(err, ErrNotFound) { - t.Fatal("foreign policy resolution", err) - } - for _, in := range []EnvironmentTemplateInput{ - {SetNetwork: true, NetworkAccess: "enabled", AllowedDomains: domains}, - {SetNetwork: true, NetworkAccess: "restricted"}, - {SetNetwork: true, NetworkAccess: "restricted", AllowedDomains: []string{"*.example.com"}}, - } { - if _, err := s.UpdateEnvironmentTemplate(ctx, tenant, created.ID, in); !errors.Is(err, ErrInvalidInput) { - t.Fatal("invalid policy replacement", err) - } - check(s.GetEnvironmentTemplate(ctx, tenant, created.ID)) - } - domains = []string{"other.example.com"} - check(s.UpdateEnvironmentTemplate(ctx, tenant, created.ID, EnvironmentTemplateInput{SetNetwork: true, NetworkAccess: "restricted", AllowedDomains: domains})) - for _, access := range []string{"disabled", "enabled"} { - value, err := s.UpdateEnvironmentTemplate(ctx, tenant, created.ID, EnvironmentTemplateInput{SetNetwork: true, NetworkAccess: access}) - if err != nil || value.NetworkAccess != access || value.AllowedDomains == nil || len(value.AllowedDomains) != 0 { - t.Fatal("policy reset", value, err) - } - } -} diff --git a/services/core/internal/store/environment_packages_test.go b/services/core/internal/store/environment_packages_test.go index c4c9d94e..2510ad3f 100644 --- a/services/core/internal/store/environment_packages_test.go +++ b/services/core/internal/store/environment_packages_test.go @@ -18,10 +18,6 @@ func TestStoredSystemPackagesRejected(t *testing.T) { tenant, session := uuid.NewString(), uuid.NewString() for _, value := range []string{`null`, `[]`, `["jq"]`} { packages := []byte(`{"npm":[],"python":[],"system":` + value + "}") - row := templateMetadataRow{Files: []byte("[]"), Skills: []byte("[]"), Plugins: []byte("[]"), Packages: packages} - if _, err := templateFromRow(row, nil); err == nil { - t.Fatal("template silently ignored removed system packages", value) - } plain := append([]byte(`{"packages":`), packages...) plain = append(plain, '}') encrypted, err := cipher.SealEnvironmentSetup(plain, credentialcrypto.EnvironmentSetupBinding{TenantID: tenant, Resource: "session", OwnerID: session, Field: "initialization"}) @@ -33,8 +29,4 @@ func TestStoredSystemPackagesRejected(t *testing.T) { t.Fatal("snapshot silently ignored removed system packages", value) } } - row := templateMetadataRow{Files: []byte("[]"), Skills: []byte("[]"), Plugins: []byte("[]"), Packages: []byte(`{"npm":["semver"],"python":["packaging"]}`)} - if _, err := templateFromRow(row, nil); err != nil { - t.Fatal("supported stored package managers rejected", err) - } } diff --git a/services/core/internal/store/environment_plugins.go b/services/core/internal/store/environment_plugins.go deleted file mode 100644 index ee26a9bd..00000000 --- a/services/core/internal/store/environment_plugins.go +++ /dev/null @@ -1,16 +0,0 @@ -package store - -import ( - "encoding/json" - - "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/environmentconfig" -) - -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 - } - contents, err := s.sealEnvironmentSetup(tenant, "environment_template", id, "plugins", setup.Plugins, len(setup.Plugins) == 0) - return metadata, contents, err -} diff --git a/services/core/internal/store/environment_plugins_test.go b/services/core/internal/store/environment_plugins_test.go index a73daaa8..4245ea76 100644 --- a/services/core/internal/store/environment_plugins_test.go +++ b/services/core/internal/store/environment_plugins_test.go @@ -14,7 +14,7 @@ import ( "github.com/google/uuid" ) -func TestPluginsEncryptedTemplateAndFrozenSession(t *testing.T) { +func TestPluginsFrozenInSession(t *testing.T) { _, pool := testStore(t) cipher, err := credentialcrypto.New(bytes.Repeat([]byte{23}, 32)) if err != nil { @@ -41,39 +41,7 @@ func TestPluginsEncryptedTemplateAndFrozenSession(t *testing.T) { } 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 { - t.Fatal(err) - } - public, err := New(pool).GetEnvironmentTemplate(t.Context(), tenant, template.ID) - if err != nil || len(public.Plugins) != 1 || !public.Initialization.Empty() || !reflect.DeepEqual(public.CapabilityDirectories, setup.CapabilityDirectories) { - t.Fatal("safe metadata without decryption", err) - } - page, err := New(pool).ListEnvironmentTemplates(t.Context(), tenant, "", 20, false) - if err != nil || len(page.Templates) != 1 || len(page.Templates[0].Plugins) != 1 { - t.Fatal("list", err) - } - var metadata, encrypted []byte - if err = pool.QueryRow(t.Context(), "SELECT plugins,plugin_contents FROM environment_templates WHERE id=$1", template.ID).Scan(&metadata, &encrypted); err != nil || len(encrypted) == 0 || bytes.Contains(metadata, []byte("private")) || bytes.Contains(encrypted, []byte("private")) { - t.Fatal("plaintext storage", err) - } - resolved, _, err := s.ResolveEnvironmentTemplate(t.Context(), tenant, template.ID) - if err != nil || !reflect.DeepEqual(resolved.Initialization.Plugins, setup.Plugins) { - t.Fatal("resolution", err) - } - for _, read := range []func() error{ - func() error { _, e := s.GetEnvironmentTemplate(t.Context(), foreign, template.ID); return e }, - func() error { _, _, e := s.ResolveEnvironmentTemplate(t.Context(), foreign, template.ID); return e }, - func() error { - _, e := s.UpdateEnvironmentTemplate(t.Context(), foreign, template.ID, EnvironmentTemplateInput{SetPlugins: true}) - return e - }, - } { - if err := read(); !errors.Is(err, ErrNotFound) { - t.Fatal("tenant isolation", err) - } - } - request := CreateSessionInput{Creator: FixtureCreator(), Engine: "codex", IdempotencyKey: uuid.NewString(), Configuration: json.RawMessage(`{"environment":{"type":"openai_hosted"}}`), Initialization: resolved.Initialization} + request := CreateSessionInput{Creator: FixtureCreator(), Engine: "codex", IdempotencyKey: uuid.NewString(), Configuration: json.RawMessage(`{"environment":{"type":"openai_hosted"}}`), Initialization: setup} session, err := s.CreateSession(t.Context(), tenant, request) if err != nil { t.Fatal(err) @@ -87,24 +55,6 @@ func TestPluginsEncryptedTemplateAndFrozenSession(t *testing.T) { if err = json.Unmarshal(session.Configuration, &cfg); err != nil || len(cfg.Environment.Plugins) != 1 || !reflect.DeepEqual(cfg.Environment.Directories, setup.CapabilityDirectories) { t.Fatal("frozen public metadata", err) } - name := "renamed" - if _, err = s.UpdateEnvironmentTemplate(t.Context(), tenant, template.ID, EnvironmentTemplateInput{SetName: true, Name: &name}); err != nil { - t.Fatal(err) - } - preserved, _, err := s.ResolveEnvironmentTemplate(t.Context(), tenant, template.ID) - if err != nil || !reflect.DeepEqual(preserved.Initialization.Plugins, setup.Plugins) || !reflect.DeepEqual(preserved.Initialization.CapabilityDirectories, setup.CapabilityDirectories) { - t.Fatal("unrelated update changed installation", err) - } - if _, err = s.UpdateEnvironmentTemplate(t.Context(), tenant, template.ID, EnvironmentTemplateInput{SetPlugins: true, SetDirectories: true}); err != nil { - t.Fatal(err) - } - cleared, _, err := s.ResolveEnvironmentTemplate(t.Context(), tenant, template.ID) - if err != nil || len(cleared.Initialization.Plugins) != 0 || len(cleared.CapabilityDirectories) != 0 { - t.Fatal("clear", err) - } - if _, err = s.DeleteEnvironmentTemplate(t.Context(), tenant, template.ID); err != nil { - t.Fatal(err) - } frozen, err := s.ReadEnvironmentSetup(t.Context(), tenant, session.ID) if err != nil || !reflect.DeepEqual(frozen.Plugins, setup.Plugins) || !reflect.DeepEqual(frozen.CapabilityDirectories, setup.CapabilityDirectories) { t.Fatal("frozen snapshot changed", err) diff --git a/services/core/internal/store/environment_setup.go b/services/core/internal/store/environment_setup.go index 0eb3dec2..081be4d7 100644 --- a/services/core/internal/store/environment_setup.go +++ b/services/core/internal/store/environment_setup.go @@ -13,10 +13,7 @@ import ( "github.com/jackc/pgx/v5/pgtype" ) -func (s *Store) sealEnvironmentSetup(tenant, resource, id, field string, input any, empty bool) ([]byte, error) { - if empty { - return nil, nil - } +func (s *Store) sealEnvironmentSetup(tenant, resource, id, field string, input any) ([]byte, error) { plaintext, err := json.Marshal(input) if err != nil { return nil, err @@ -49,7 +46,7 @@ func (s *Store) saveEnvironmentSetup(ctx context.Context, q *sqlc.Queries, tenan if err != nil { return err } - encrypted, err := s.sealEnvironmentSetup(uuid.UUID(owner.Bytes).String(), "session", uuid.UUID(session.Bytes).String(), "initialization", setup, false) + encrypted, err := s.sealEnvironmentSetup(uuid.UUID(owner.Bytes).String(), "session", uuid.UUID(session.Bytes).String(), "initialization", setup) if err != nil { return err } @@ -77,16 +74,3 @@ func (s *Store) ReadEnvironmentSetup(ctx context.Context, tenant, session string } return result, nil } - -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 - } - env, err := s.sealEnvironmentSetup(tenant, "environment_template", id, "env", setup.Env, len(setup.Env) == 0) - if err != nil { - return nil, nil, nil, err - } - commands, err := s.sealEnvironmentSetup(tenant, "environment_template", id, "setup_commands", setup.Commands, len(setup.Commands) == 0) - return packages, env, commands, err -} diff --git a/services/core/internal/store/environment_setup_test.go b/services/core/internal/store/environment_setup_test.go index cbc42c33..1a2f02ab 100644 --- a/services/core/internal/store/environment_setup_test.go +++ b/services/core/internal/store/environment_setup_test.go @@ -21,36 +21,8 @@ func TestEnvironmentSetupEncryptedSnapshotAndIsolation(t *testing.T) { } s := NewWithCredentialCipher(pool, cipher) tenant, foreign := uuid.NewString(), uuid.NewString() - 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) - } - public, err := New(pool).GetEnvironmentTemplate(t.Context(), tenant, template.ID) - if err != nil || len(public.Packages.NPM) != 1 || !reflect.DeepEqual(public.Packages.NPM, setup.Packages.NPM) || !public.Initialization.Empty() { - t.Fatal("public metadata requires plaintext or key", err) - } - resolved, _, err := s.ResolveEnvironmentTemplate(t.Context(), tenant, template.ID) - if err != nil || !reflect.DeepEqual(resolved.Initialization.Env, setup.Env) || !reflect.DeepEqual(resolved.Initialization.Commands, setup.Commands) { - t.Fatal("confidential configuration resolution", err) - } - if _, _, err := s.ResolveEnvironmentTemplate(t.Context(), foreign, template.ID); !errors.Is(err, ErrNotFound) { - t.Fatal("foreign resolution", err) - } - var envCipher, commandCipher []byte - if err := pool.QueryRow(t.Context(), "SELECT env_contents, setup_contents FROM environment_templates WHERE id=$1", template.ID).Scan(&envCipher, &commandCipher); err != nil || bytes.Contains(envCipher, []byte("template-env-canary")) || bytes.Contains(commandCipher, []byte("template-command-canary")) { - t.Fatal("plaintext template storage", err) - } - // Unrelated updates preserve both confidential fields; replacement is scoped. - name := "renamed" - if _, err := s.UpdateEnvironmentTemplate(t.Context(), tenant, template.ID, EnvironmentTemplateInput{SetName: true, Name: &name}); err != nil { - t.Fatal(err) - } - retained, _, err := s.ResolveEnvironmentTemplate(t.Context(), tenant, template.ID) - if err != nil || !reflect.DeepEqual(retained.Initialization, resolved.Initialization) { - t.Fatal("name update changed initialization", err) - } - input := CreateSessionInput{Creator: FixtureCreator(), Engine: "codex", IdempotencyKey: uuid.NewString(), Configuration: json.RawMessage(`{"environment":{"type":"openai_hosted"}}`), Initialization: resolved.Initialization} + setup := environmentconfig.Setup{Env: map[string]string{"SECRET": "session-env-canary"}, Commands: []environmentconfig.SetupCommand{{Command: "printf session-command-canary > result"}}, Packages: v1.EnvironmentPackages{NPM: []string{"is-number@7.0.0"}}} + input := CreateSessionInput{Creator: FixtureCreator(), Engine: "codex", IdempotencyKey: uuid.NewString(), Configuration: json.RawMessage(`{"environment":{"type":"openai_hosted"}}`), Initialization: setup} session, err := s.CreateSession(t.Context(), tenant, input) if err != nil { t.Fatal(err) @@ -58,18 +30,8 @@ func TestEnvironmentSetupEncryptedSnapshotAndIsolation(t *testing.T) { if bytes.Contains(session.Configuration, []byte("canary")) { t.Fatal("plaintext Session metadata") } - if _, err := s.UpdateEnvironmentTemplate(t.Context(), tenant, template.ID, EnvironmentTemplateInput{SetEnv: true}); err != nil { - t.Fatal(err) - } - cleared, _, err := s.ResolveEnvironmentTemplate(t.Context(), tenant, template.ID) - if err != nil || len(cleared.Initialization.Env) != 0 || !reflect.DeepEqual(cleared.Initialization.Commands, setup.Commands) { - t.Fatal("field replacement lost unrelated values", err) - } - if _, err := s.DeleteEnvironmentTemplate(t.Context(), tenant, template.ID); err != nil { - t.Fatal(err) - } frozen, err := s.ReadEnvironmentSetup(t.Context(), tenant, session.ID) - if err != nil || !reflect.DeepEqual(frozen, resolved.Initialization) { + if err != nil || !reflect.DeepEqual(frozen, setup) { t.Fatal("Session did not freeze setup", err) } if _, err := s.ReadEnvironmentSetup(t.Context(), foreign, session.ID); !errors.Is(err, ErrNotFound) { diff --git a/services/core/internal/store/environment_skill_references_test.go b/services/core/internal/store/environment_skill_references_test.go index 3c1ead0a..a6c94c62 100644 --- a/services/core/internal/store/environment_skill_references_test.go +++ b/services/core/internal/store/environment_skill_references_test.go @@ -29,15 +29,7 @@ func TestSkillReferencesFreezeWithinSessionCreation(t *testing.T) { t.Fatal(err) } 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) - } - resolved, _, err := s.ResolveEnvironmentTemplate(t.Context(), tenant, template.ID) - if err != nil || resolved.Skills[0].Version != "" || len(resolved.Initialization.Skills[0].Archive) != 0 { - t.Fatal("template resolved a mutable selector", err) - } - input := CreateSessionInput{Creator: FixtureCreator(), Engine: "codex", IdempotencyKey: uuid.NewString(), Configuration: json.RawMessage(`{"environment":{"type":"openai_hosted"}}`), Initialization: resolved.Initialization} + input := CreateSessionInput{Creator: FixtureCreator(), Engine: "codex", IdempotencyKey: uuid.NewString(), Configuration: json.RawMessage(`{"environment":{"type":"openai_hosted"}}`), Initialization: intent} // Concurrent callers share one Session and one frozen installation. var group sync.WaitGroup ids := make(chan string, 6) @@ -115,9 +107,6 @@ func TestSkillReferencesFreezeWithinSessionCreation(t *testing.T) { assertFrozen(created.ID, "2", second) } } - if _, err = s.DeleteEnvironmentTemplate(t.Context(), tenant, template.ID); err != nil { - t.Fatal(err) - } if err = s.DeleteSkill(t.Context(), tenant, skill.ID); err != nil { t.Fatal(err) } diff --git a/services/core/internal/store/environment_skills.go b/services/core/internal/store/environment_skills.go deleted file mode 100644 index 33cccf31..00000000 --- a/services/core/internal/store/environment_skills.go +++ /dev/null @@ -1,16 +0,0 @@ -package store - -import ( - "encoding/json" - - "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/environmentconfig" -) - -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 - } - contents, err := s.sealEnvironmentSetup(tenant, "environment_template", id, "skills", setup.Skills, len(setup.Skills) == 0) - return metadata, contents, err -} diff --git a/services/core/internal/store/environment_skills_test.go b/services/core/internal/store/environment_skills_test.go index 646eef3d..f8d9800a 100644 --- a/services/core/internal/store/environment_skills_test.go +++ b/services/core/internal/store/environment_skills_test.go @@ -13,7 +13,7 @@ import ( "github.com/google/uuid" ) -func TestSkillsEncryptedTemplateAndFrozenSession(t *testing.T) { +func TestSkillsFrozenInSession(t *testing.T) { _, pool := testStore(t) cipher, err := credentialcrypto.New(bytes.Repeat([]byte{17}, 32)) if err != nil { @@ -35,44 +35,10 @@ func TestSkillsEncryptedTemplateAndFrozenSession(t *testing.T) { } 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}) + session, err := s.CreateSession(t.Context(), tenant, CreateSessionInput{Creator: FixtureCreator(), Engine: "codex", IdempotencyKey: uuid.NewString(), Configuration: json.RawMessage(`{"environment":{"type":"openai_hosted"}}`), Initialization: setup}) if err != nil { t.Fatal(err) } - public, err := New(pool).GetEnvironmentTemplate(t.Context(), tenant, template.ID) - if err != nil || len(public.Skills) != 1 || !public.Initialization.Empty() { - t.Fatal("public metadata", err) - } - var metadata, encrypted []byte - if err = pool.QueryRow(t.Context(), "SELECT skills,skill_contents FROM environment_templates WHERE id=$1", template.ID).Scan(&metadata, &encrypted); err != nil || bytes.Contains(metadata, []byte("canary")) || bytes.Contains(encrypted, []byte("canary")) { - t.Fatal("plaintext storage", err) - } - resolved, _, err := s.ResolveEnvironmentTemplate(t.Context(), tenant, template.ID) - if err != nil || !reflect.DeepEqual(resolved.Initialization.Skills, setup.Skills) { - t.Fatal("resolution", err) - } - if _, _, err = s.ResolveEnvironmentTemplate(t.Context(), foreign, template.ID); !errors.Is(err, ErrNotFound) { - t.Fatal("tenant isolation", err) - } - session, err := s.CreateSession(t.Context(), tenant, CreateSessionInput{Creator: FixtureCreator(), Engine: "codex", IdempotencyKey: uuid.NewString(), Configuration: json.RawMessage(`{"environment":{"type":"openai_hosted"}}`), Initialization: resolved.Initialization}) - if err != nil { - t.Fatal(err) - } - name := "renamed" - if _, err = s.UpdateEnvironmentTemplate(t.Context(), tenant, template.ID, EnvironmentTemplateInput{SetName: true, Name: &name}); err != nil { - t.Fatal(err) - } - preserved, _, err := s.ResolveEnvironmentTemplate(t.Context(), tenant, template.ID) - if err != nil || !reflect.DeepEqual(preserved.Initialization.Skills, setup.Skills) { - t.Fatal("unrelated update", err) - } - cleared, err := s.UpdateEnvironmentTemplate(t.Context(), tenant, template.ID, EnvironmentTemplateInput{SetSkills: true}) - if err != nil || len(cleared.Skills) != 0 { - t.Fatal("clearing", err) - } - if _, err = s.DeleteEnvironmentTemplate(t.Context(), tenant, template.ID); err != nil { - t.Fatal(err) - } frozen, err := s.ReadEnvironmentSetup(t.Context(), tenant, session.ID) if err != nil || !reflect.DeepEqual(frozen.Skills, setup.Skills) { t.Fatal("frozen content changed", err) diff --git a/services/core/internal/store/environment_templates.go b/services/core/internal/store/environment_templates.go deleted file mode 100644 index 530ec74e..00000000 --- a/services/core/internal/store/environment_templates.go +++ /dev/null @@ -1,248 +0,0 @@ -package store - -import ( - "context" - "encoding/json" - "errors" - "time" - "unicode/utf8" - - "github.com/MiniMax-AI/OpenAgentCore/internal/agentnetwork" - "github.com/MiniMax-AI/OpenAgentCore/internal/agentplugin" - - 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/MiniMax-AI/OpenAgentCore/services/core/internal/persistence/postgres/auditpg" - "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/persistence/postgres/pgunit" - "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/writeaudit" - "github.com/google/uuid" - "github.com/jackc/pgx/v5" - "github.com/jackc/pgx/v5/pgtype" -) - -// EnvironmentTemplate is configuration ownership, independent of provider images. - -type EnvironmentTemplate struct { - Plugins []agentplugin.Metadata - CapabilityDirectories []string - Skills []environmentconfig.SkillMetadata - Packages v1.EnvironmentPackages - Initialization environmentconfig.Setup - Files []environmentconfig.InitialFileMetadata - ID string - Name *string - NetworkAccess string - AllowedDomains []string - CreatedAt time.Time - UpdatedAt time.Time -} - -type EnvironmentTemplateInput struct { - Initialization environmentconfig.Setup - SetEnv, SetSetup, SetPackages, SetSkills, SetPlugins, SetDirectories bool - Files []environmentconfig.InitialFile - SetFiles bool - Name *string - SetName bool - NetworkAccess string - AllowedDomains []string - SetNetwork bool -} - -func (in EnvironmentTemplateInput) valid() bool { - return in.Initialization.Validate() == nil && (in.Name == nil || (utf8.ValidString(*in.Name) && utf8.RuneCountInString(*in.Name) >= 1 && utf8.RuneCountInString(*in.Name) <= 256)) && - (!in.SetNetwork || (agentnetwork.Policy{Access: in.NetworkAccess, AllowedDomains: in.AllowedDomains}).Validate() == nil) -} - -type templateMetadataRow sqlc.GetEnvironmentTemplateRow - -func templateFromRow(row templateMetadataRow, err error) (EnvironmentTemplate, error) { - if errors.Is(err, pgx.ErrNoRows) { - return EnvironmentTemplate{}, ErrNotFound - } - if err != nil { - return EnvironmentTemplate{}, err - } - result := EnvironmentTemplate{CapabilityDirectories: append([]string{}, row.CapabilityDirectories...), ID: uuid.UUID(row.ID.Bytes).String(), NetworkAccess: row.NetworkAccess, AllowedDomains: append([]string{}, row.NetworkAllowedDomains...), CreatedAt: row.CreatedAt.Time, UpdatedAt: row.UpdatedAt.Time} - if row.Name.Valid { - result.Name = &row.Name.String - } - 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 -} - -func (s *Store) CreateEnvironmentTemplate(ctx context.Context, tenantID string, in EnvironmentTemplateInput) (EnvironmentTemplate, error) { - if !in.SetNetwork { - in.NetworkAccess = "enabled" - in.AllowedDomains = nil - in.SetNetwork = true - } - if !in.valid() { - return EnvironmentTemplate{}, ErrInvalidInput - } - tenant, err := parseID(tenantID) - if err != nil { - return EnvironmentTemplate{}, err - } - var name pgtype.Text - if in.Name != nil { - name = pgtype.Text{String: *in.Name, Valid: true} - } - id := uuid.New() - metadata, encrypted, err := s.sealTemplateFiles(uuid.UUID(tenant.Bytes).String(), id.String(), in.Files) - if err != nil { - return EnvironmentTemplate{}, err - } - packages, envContents, setupContents, err := s.sealTemplateSetup(uuid.UUID(tenant.Bytes).String(), id.String(), in.Initialization) - if err != nil { - return EnvironmentTemplate{}, err - } - skills, skillContents, err := s.sealTemplateSkills(uuid.UUID(tenant.Bytes).String(), id.String(), in.Initialization) - if err != nil { - return EnvironmentTemplate{}, err - } - plugins, pluginContents, err := s.sealTemplatePlugins(uuid.UUID(tenant.Bytes).String(), id.String(), in.Initialization) - if err != nil { - return EnvironmentTemplate{}, err - } - var result EnvironmentTemplate - err = s.pooled.Transaction(ctx, func(ctx context.Context, tx pgx.Tx) error { - q := s.queries.WithTx(tx) - row, err := q.CreateEnvironmentTemplate(ctx, sqlc.CreateEnvironmentTemplateParams{ID: pgtype.UUID{Bytes: id, Valid: true}, TenantID: tenant, Name: name, NetworkAccess: in.NetworkAccess, NetworkAllowedDomains: append([]string{}, in.AllowedDomains...), Files: metadata, FileContents: encrypted, Packages: packages, EnvContents: envContents, SetupContents: setupContents, Skills: skills, SkillContents: skillContents, Plugins: plugins, PluginContents: pluginContents, CapabilityDirectories: append([]string{}, in.Initialization.CapabilityDirectories...)}) - result, err = templateFromRow(templateMetadataRow(row), err) - if err != nil { - return err - } - return auditpg.RecordWriteAudit(ctx, q, tenantID, "create", "environment_template", result.ID, "", writeaudit.Resource{Type: "environment_template", ID: result.ID}) - }) - return result, err -} - -func (s *Store) GetEnvironmentTemplate(ctx context.Context, tenantID, templateID string) (EnvironmentTemplate, error) { - tenant, err := parseID(tenantID) - if err != nil { - return EnvironmentTemplate{}, err - } - id, err := parseID(templateID) - if err != nil { - return EnvironmentTemplate{}, ErrNotFound - } - row, err := s.queries.GetEnvironmentTemplate(ctx, sqlc.GetEnvironmentTemplateParams{TenantID: tenant, ID: id}) - return templateFromRow(templateMetadataRow(row), err) -} - -// Each supplied field replaces atomically, preserving concurrent unrelated updates. -func (s *Store) UpdateEnvironmentTemplate(ctx context.Context, tenantID, templateID string, in EnvironmentTemplateInput) (EnvironmentTemplate, error) { - if !in.valid() { - return EnvironmentTemplate{}, ErrInvalidInput - } - tenant, err := parseID(tenantID) - if err != nil { - return EnvironmentTemplate{}, err - } - // Sealing runs before the update lookup, so a malformed ID must take the same path. - id := pgunit.PathID(templateID) - var name pgtype.Text - if in.Name != nil { - name = pgtype.Text{String: *in.Name, Valid: true} - } - metadata, encrypted, err := s.sealTemplateFiles(uuid.UUID(tenant.Bytes).String(), uuid.UUID(id.Bytes).String(), in.Files) - if err != nil { - return EnvironmentTemplate{}, err - } - packages, envContents, setupContents, err := s.sealTemplateSetup(uuid.UUID(tenant.Bytes).String(), uuid.UUID(id.Bytes).String(), in.Initialization) - if err != nil { - return EnvironmentTemplate{}, err - } - skills, skillContents, err := s.sealTemplateSkills(uuid.UUID(tenant.Bytes).String(), uuid.UUID(id.Bytes).String(), in.Initialization) - if err != nil { - return EnvironmentTemplate{}, err - } - plugins, pluginContents, err := s.sealTemplatePlugins(uuid.UUID(tenant.Bytes).String(), uuid.UUID(id.Bytes).String(), in.Initialization) - if err != nil { - return EnvironmentTemplate{}, err - } - var result EnvironmentTemplate - err = s.pooled.Transaction(ctx, func(ctx context.Context, tx pgx.Tx) error { - q := s.queries.WithTx(tx) - row, err := q.UpdateEnvironmentTemplate(ctx, sqlc.UpdateEnvironmentTemplateParams{TenantID: tenant, ID: id, Name: name, SetName: in.SetName, NetworkAccess: in.NetworkAccess, NetworkAllowedDomains: append([]string{}, in.AllowedDomains...), SetNetwork: in.SetNetwork, SetFiles: in.SetFiles, Files: metadata, FileContents: encrypted, Packages: packages, EnvContents: envContents, SetupContents: setupContents, SetPackages: in.SetPackages, SetEnv: in.SetEnv, SetSetup: in.SetSetup, SetSkills: in.SetSkills, SetPlugins: in.SetPlugins, SetDirectories: in.SetDirectories, Skills: skills, SkillContents: skillContents, Plugins: plugins, PluginContents: pluginContents, CapabilityDirectories: append([]string{}, in.Initialization.CapabilityDirectories...)}) - result, err = templateFromRow(templateMetadataRow(row), err) - if err != nil { - return err - } - return auditpg.RecordWriteAudit(ctx, q, tenantID, "update", "environment_template", result.ID, "") - }) - return result, err -} - -func (s *Store) DeleteEnvironmentTemplate(ctx context.Context, tenantID, templateID string) (string, error) { - tenant, err := parseID(tenantID) - if err != nil { - return "", err - } - id, err := parseID(templateID) - if err != nil { - return "", ErrNotFound - } - var deletedID string - err = s.pooled.Transaction(ctx, func(ctx context.Context, tx pgx.Tx) error { - q := s.queries.WithTx(tx) - result, err := q.DeleteEnvironmentTemplate(ctx, sqlc.DeleteEnvironmentTemplateParams{TenantID: tenant, ID: id}) - if err != nil { - return err - } - deletedID = uuid.UUID(result.Bytes).String() - return auditpg.RecordWriteAudit(ctx, q, tenantID, "delete", "environment_template", deletedID, "") - }) - if errors.Is(err, pgx.ErrNoRows) { - return "", ErrNotFound - } - if err != nil { - return "", err - } - return deletedID, nil -} - -type EnvironmentTemplatePage struct { - Templates []EnvironmentTemplate - HasMore bool -} - -func (s *Store) ListEnvironmentTemplates(ctx context.Context, tenantID, cursor string, limit int, ascending bool) (EnvironmentTemplatePage, error) { - tenant, err := parseID(tenantID) - if err != nil { - return EnvironmentTemplatePage{}, err - } - if limit < 1 || limit > 100 { - return EnvironmentTemplatePage{}, ErrInvalidInput - } - params := sqlc.ListEnvironmentTemplatesParams{TenantID: tenant, PageLimit: int32(limit + 1), AfterID: pgtype.UUID{Valid: true}, Ascending: ascending} - if cursor != "" { - after, err := s.GetEnvironmentTemplate(ctx, tenantID, pgunit.LookupCursor(cursor)) - if err != nil { - return EnvironmentTemplatePage{}, err - } - params.AfterCreated = pgtype.Timestamptz{Time: after.CreatedAt, Valid: true} - params.AfterID, _ = parseID(after.ID) - } - rows, err := s.queries.ListEnvironmentTemplates(ctx, params) - if err != nil { - return EnvironmentTemplatePage{}, err - } - page := EnvironmentTemplatePage{Templates: make([]EnvironmentTemplate, 0, min(limit, len(rows))), HasMore: len(rows) > limit} - if len(rows) > limit { - rows = rows[:limit] - } - for _, row := range rows { - value, err := templateFromRow(templateMetadataRow(row), nil) - if err != nil { - return EnvironmentTemplatePage{}, err - } - page.Templates = append(page.Templates, value) - } - return page, nil -} diff --git a/services/core/internal/store/environment_templates_noop_test.go b/services/core/internal/store/environment_templates_noop_test.go deleted file mode 100644 index b267ef90..00000000 --- a/services/core/internal/store/environment_templates_noop_test.go +++ /dev/null @@ -1,57 +0,0 @@ -package store - -import ( - "bytes" - "errors" - "reflect" - "testing" - - "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/credentialcrypto" - "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/environmentconfig" - "github.com/google/uuid" -) - -func TestTemplateEmptyUpdateTouchesTimeWithoutDecryptingOrChangingContents(t *testing.T) { - keyless, pool := testStore(t) - cipher, err := credentialcrypto.New(bytes.Repeat([]byte{37}, 32)) - if err != nil { - t.Fatal(err) - } - s := NewWithCredentialCipher(pool, cipher) - tenant, name := uuid.NewString(), "Retained template" - original, err := s.CreateEnvironmentTemplate(t.Context(), tenant, EnvironmentTemplateInput{ - Name: &name, - 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: []environmentconfig.SetupCommand{{Command: "echo setup-canary"}}, - }, - }) - if err != nil { - t.Fatal(err) - } - readContents := func() []byte { - t.Helper() - var contents []byte - if err := pool.QueryRow(t.Context(), "SELECT to_jsonb(t) - 'updated_at' FROM environment_templates t WHERE id=$1", original.ID).Scan(&contents); err != nil { - t.Fatal(err) - } - return contents - } - before := readContents() - if _, err := keyless.UpdateEnvironmentTemplate(t.Context(), uuid.NewString(), original.ID, EnvironmentTemplateInput{}); !errors.Is(err, ErrNotFound) { - t.Fatal("foreign empty update was admitted", err) - } - updated, err := keyless.UpdateEnvironmentTemplate(t.Context(), tenant, original.ID, EnvironmentTemplateInput{}) - if err != nil || !updated.UpdatedAt.After(original.UpdatedAt) { - t.Fatal("empty update did not advance timestamp without a key", err) - } - original.UpdatedAt = updated.UpdatedAt - if !reflect.DeepEqual(updated, original) || !bytes.Equal(before, readContents()) { - t.Fatal("empty update changed template metadata, ciphertext or ownership") - } - retained, _, err := s.ResolveEnvironmentTemplate(t.Context(), tenant, original.ID) - if err != nil || retained.Initialization.Env["PRIVATE_SETUP"] != "env-canary" || len(retained.Initialization.Commands) != 1 { - t.Fatal("empty update invalidated confidential setup", err) - } -} diff --git a/services/core/internal/store/environment_templates_test.go b/services/core/internal/store/environment_templates_test.go deleted file mode 100644 index 2b0f6562..00000000 --- a/services/core/internal/store/environment_templates_test.go +++ /dev/null @@ -1,88 +0,0 @@ -package store - -import ( - "errors" - "github.com/google/uuid" - "sync" - "testing" -) - -func TestEnvironmentTemplatesDurabilityIsolationAndConcurrentUpdates(t *testing.T) { - s, pool := testStore(t) - ctx := t.Context() - tenant, foreign := uuid.NewString(), uuid.NewString() - name := " template " - created, err := s.CreateEnvironmentTemplate(ctx, tenant, EnvironmentTemplateInput{Name: &name}) - if err != nil || created.NetworkAccess != "enabled" || created.Name == nil || *created.Name != name || !created.CreatedAt.Equal(created.UpdatedAt) { - t.Fatal(created, err) - } - for _, target := range []string{foreign} { - if _, err := s.GetEnvironmentTemplate(ctx, target, created.ID); !errors.Is(err, ErrNotFound) { - t.Fatal("foreign read", err) - } - if _, err := s.UpdateEnvironmentTemplate(ctx, target, created.ID, EnvironmentTemplateInput{SetName: true}); !errors.Is(err, ErrNotFound) { - t.Fatal("foreign update", err) - } - if _, err := s.DeleteEnvironmentTemplate(ctx, target, created.ID); !errors.Is(err, ErrNotFound) { - t.Fatal("foreign delete", err) - } - if _, err := s.ListEnvironmentTemplates(ctx, target, created.ID, 1, false); !errors.Is(err, ErrNotFound) { - t.Fatal("foreign cursor", err) - } - } - newName := "changed" - var wg sync.WaitGroup - for _, in := range []EnvironmentTemplateInput{{Name: &newName, SetName: true}, {NetworkAccess: "disabled", SetNetwork: true}} { - wg.Add(1) - go func(in EnvironmentTemplateInput) { - defer wg.Done() - if _, err := s.UpdateEnvironmentTemplate(ctx, tenant, created.ID, in); err != nil { - t.Error(err) - } - }(in) - } - wg.Wait() - got, err := s.GetEnvironmentTemplate(ctx, tenant, created.ID) - if err != nil || got.NetworkAccess != "disabled" || got.Name == nil || *got.Name != newName || !got.CreatedAt.Equal(created.CreatedAt) { - t.Fatal("lost concurrent update", got, err) - } - pool.Close() - s, _ = testStore(t) - got, err = s.GetEnvironmentTemplate(ctx, tenant, created.ID) - if err != nil || got.NetworkAccess != "disabled" { - t.Fatal("lost durable template", got, err) - } - ids := []string{created.ID} - for range 3 { - v, err := s.CreateEnvironmentTemplate(ctx, tenant, EnvironmentTemplateInput{}) - if err != nil { - t.Fatal(err) - } - ids = append(ids, v.ID) - } - first, err := s.ListEnvironmentTemplates(ctx, tenant, "", 2, true) - if err != nil || !first.HasMore || len(first.Templates) != 2 || first.Templates[0].ID != ids[0] { - t.Fatal(first, err) - } - second, err := s.ListEnvironmentTemplates(ctx, tenant, first.Templates[1].ID, 2, true) - if err != nil || second.HasMore || len(second.Templates) != 2 || second.Templates[0].ID != ids[2] { - t.Fatal(second, err) - } - reverse, err := s.ListEnvironmentTemplates(ctx, tenant, "", 1, false) - if err != nil || reverse.Templates[0].ID != ids[3] { - t.Fatal(reverse, err) - } - cleared, err := s.UpdateEnvironmentTemplate(ctx, tenant, created.ID, EnvironmentTemplateInput{SetName: true, SetNetwork: true, NetworkAccess: "enabled"}) - if err != nil || cleared.Name != nil || cleared.NetworkAccess != "enabled" { - t.Fatal(cleared, err) - } - if id, err := s.DeleteEnvironmentTemplate(ctx, tenant, created.ID); err != nil || id != created.ID { - t.Fatal(id, err) - } - if _, err := s.GetEnvironmentTemplate(ctx, tenant, created.ID); !errors.Is(err, ErrNotFound) { - t.Fatal(err) - } - if _, err := s.CreateEnvironmentTemplate(ctx, tenant, EnvironmentTemplateInput{SetNetwork: true, NetworkAccess: "restricted"}); !errors.Is(err, ErrInvalidInput) { - t.Fatal(err) - } -} diff --git a/services/core/internal/store/initial_files.go b/services/core/internal/store/initial_files.go index 8182d914..e9758d9f 100644 --- a/services/core/internal/store/initial_files.go +++ b/services/core/internal/store/initial_files.go @@ -15,91 +15,6 @@ import ( "github.com/jackc/pgx/v5/pgtype" ) -func (s *Store) sealTemplateFiles(tenant, id string, files []environmentconfig.InitialFile) ([]byte, []byte, error) { - if environmentconfig.ValidateInitialFiles(files) != nil { - return nil, nil, ErrInvalidInput - } - metadata, err := json.Marshal(environmentconfig.InitialFilesMetadata(files)) - if err != nil { - return nil, nil, err - } - if len(files) == 0 { - return metadata, nil, nil - } - input, err := json.Marshal(files) - if err != nil { - return nil, nil, err - } - encrypted, err := s.credentialCipher.SealEnvironmentFile(input, credentialcrypto.EnvironmentFileBinding{TenantID: tenant, Resource: "environment_template", OwnerID: id, FileID: "files"}) - return metadata, encrypted, err -} - -// ResolveEnvironmentTemplate reads one atomic snapshot; public reads need no decryption key. -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, []environmentconfig.InitialFile, error) { - lookup, err := deviceLookup(tenant, id) - if err != nil { - return EnvironmentTemplate{}, nil, ErrNotFound - } - row, err := q.ResolveEnvironmentTemplate(ctx, sqlc.ResolveEnvironmentTemplateParams{TenantID: lookup.TenantID, ID: lookup.ID}) - value, err := templateFromRow(templateMetadataRow{ID: row.ID, TenantID: row.TenantID, Name: row.Name, NetworkAccess: row.NetworkAccess, NetworkAllowedDomains: row.NetworkAllowedDomains, CreatedAt: row.CreatedAt, UpdatedAt: row.UpdatedAt, Files: row.Files, Packages: row.Packages, Skills: row.Skills, Plugins: row.Plugins, CapabilityDirectories: row.CapabilityDirectories}, err) - if err != nil { - return value, nil, err - } - value.Initialization.Packages = value.Packages - canonicalTenant := uuid.UUID(lookup.TenantID.Bytes).String() - if err = s.openEnvironmentSetup(canonicalTenant, "environment_template", value.ID, "env", row.EnvContents, &value.Initialization.Env); err != nil { - return value, nil, err - } - if err = s.openEnvironmentSetup(canonicalTenant, "environment_template", value.ID, "setup_commands", row.SetupContents, &value.Initialization.Commands); err != nil { - return value, nil, err - } - if err = s.openEnvironmentSetup(canonicalTenant, "environment_template", value.ID, "skills", row.SkillContents, &value.Initialization.Skills); err != nil { - return value, nil, err - } - if len(value.Skills) != len(value.Initialization.Skills) { - return value, nil, ErrInvalidInput - } - for i, metadata := range value.Skills { - if metadata != value.Initialization.Skills[i].Metadata { - return value, nil, ErrInvalidInput - } - } - value.Initialization.CapabilityDirectories = append([]string(nil), value.CapabilityDirectories...) - if err = s.openEnvironmentSetup(canonicalTenant, "environment_template", value.ID, "plugins", row.PluginContents, &value.Initialization.Plugins); err != nil { - return value, nil, err - } - if len(value.Plugins) != len(value.Initialization.Plugins) { - return value, nil, ErrInvalidInput - } - for i, metadata := range value.Plugins { - if metadata != value.Initialization.Plugins[i].Metadata { - return value, nil, ErrInvalidInput - } - } - if value.Initialization.Validate() != nil { - return value, nil, ErrInvalidInput - } - if len(row.FileContents) == 0 { - if len(value.Files) > 0 { - return value, nil, ErrInvalidInput - } - return value, nil, nil - } - plain, err := s.credentialCipher.OpenEnvironmentFile(row.FileContents, credentialcrypto.EnvironmentFileBinding{TenantID: uuid.UUID(lookup.TenantID.Bytes).String(), Resource: "environment_template", OwnerID: value.ID, FileID: "files"}) - if err != nil { - return value, nil, err - } - 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, initial []environmentconfig.InitialFile) ([]byte, error) { if environmentconfig.ValidateInitialFiles(initial) != nil { return nil, ErrInvalidInput diff --git a/services/core/internal/store/initial_files_test.go b/services/core/internal/store/initial_files_test.go index 2b3cad0e..38fa96a3 100644 --- a/services/core/internal/store/initial_files_test.go +++ b/services/core/internal/store/initial_files_test.go @@ -50,28 +50,7 @@ func TestInitialFilesFrozenEncryptedIsolatedAndRetryable(t *testing.T) { t.Fatal(err) } initial := []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: initial}) - if err != nil { - t.Fatal(err) - } - public, err := New(pool).GetEnvironmentTemplate(t.Context(), tenant, template.ID) - if err != nil || len(public.Files) != 2 { - t.Fatal("public read depends on secret key", err) - } - if _, _, err := s.ResolveEnvironmentTemplate(t.Context(), foreign, template.ID); !errors.Is(err, ErrNotFound) { - t.Fatal("foreign template resolved", err) - } - if _, err := s.UpdateEnvironmentTemplate(t.Context(), strings.ToUpper(tenant), strings.ToUpper(template.ID), EnvironmentTemplateInput{SetFiles: true, Files: initial}); err != nil { - t.Fatal("noncanonical update", err) - } - if _, _, err := s.ResolveEnvironmentTemplate(t.Context(), strings.ToUpper(tenant), strings.ToUpper(template.ID)); err != nil { - t.Fatal("noncanonical resolution", err) - } - _, resolved, err := s.ResolveEnvironmentTemplate(t.Context(), tenant, template.ID) - if err != nil || !bytes.Equal(resolved[0].Data, canary) { - t.Fatal("template snapshot", err) - } - input := CreateSessionInput{Creator: FixtureCreator(), Engine: "codex", IdempotencyKey: uuid.NewString(), Configuration: json.RawMessage(`{"environment":{"type":"openai_hosted"}}`), InitialFiles: resolved} + input := CreateSessionInput{Creator: FixtureCreator(), Engine: "codex", IdempotencyKey: uuid.NewString(), Configuration: json.RawMessage(`{"environment":{"type":"openai_hosted"}}`), InitialFiles: initial} session, err := s.CreateSession(t.Context(), tenant, input) if err != nil { t.Fatal(err) @@ -79,12 +58,6 @@ func TestInitialFilesFrozenEncryptedIsolatedAndRetryable(t *testing.T) { if bytes.Contains(session.Configuration, canary) || bytes.Contains(session.Configuration, []byte(`"data"`)) { t.Fatal("plaintext in Session configuration") } - if _, err := s.UpdateEnvironmentTemplate(t.Context(), tenant, template.ID, EnvironmentTemplateInput{SetFiles: true}); err != nil { - t.Fatal(err) - } - if _, err := s.DeleteEnvironmentTemplate(t.Context(), tenant, template.ID); err != nil { - t.Fatal(err) - } if err := sourceFiles.Delete(t.Context(), files.DeleteCommand{TenantID: tenant, FileID: upload.ID}); err != nil { t.Fatal(err) } diff --git a/services/core/internal/store/public_handler_fixture_test.go b/services/core/internal/store/public_handler_fixture_test.go index 03d3714a..e56bd3e9 100644 --- a/services/core/internal/store/public_handler_fixture_test.go +++ b/services/core/internal/store/public_handler_fixture_test.go @@ -10,12 +10,14 @@ import ( "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/agents" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/api" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/coremetrics" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/environmenttemplates" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/execution" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/files" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/persistence/postgres/agentpg" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/persistence/postgres/auditpg" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/persistence/postgres/filepg" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/persistence/postgres/pgunit" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/persistence/postgres/templatepg" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/runtimedevice" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/runtimehistory" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/runtimeobs" @@ -47,13 +49,19 @@ func publicHandler(t testing.TB, s *store.Store, db fixtureDB, keys fixtureKeyRe if err != nil { return nil, err } + templates := templatepg.New(pgunit.NewPool(db.pool), db.cipher) + environmentTemplates, err := environmenttemplates.NewService(templates) + if err != nil { + return nil, err + } deps := api.Dependencies{ Engine: engine, CoreKeys: admin, InstallationBindings: s, Projects: fixtureProjects{Store: s, keys: keys}, ModelProviders: s, Skills: s, Vaults: vaultService, VaultsReader: vaultStore, Files: fileService, FilesReader: fileStore, + EnvironmentTemplates: environmentTemplates, EnvironmentTemplatesReader: templates, Agents: agentService, AgentsReader: agentStore, - EnvironmentTemplates: s, Sessions: s, SessionEvents: s, SessionHistory: s, Subagents: s, + Sessions: s, SessionEvents: s, SessionHistory: s, Subagents: s, Artifacts: s, SessionAdmin: s, Environments: s, Admin: s, AdminAudit: audit, WriteAudit: audit, ExecutorConnections: strict, Metrics: strict, RuntimeObservations: strict, RuntimeHistory: strict, } diff --git a/services/core/internal/store/skill_version_deletion_test.go b/services/core/internal/store/skill_version_deletion_test.go index ea059769..2f1d7c1b 100644 --- a/services/core/internal/store/skill_version_deletion_test.go +++ b/services/core/internal/store/skill_version_deletion_test.go @@ -47,10 +47,6 @@ func TestSoleSkillVersionDeletionRemovesSkill(t *testing.T) { t.Fatal(err) } 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) - } input := CreateSessionInput{Creator: FixtureCreator(), Engine: "codex", IdempotencyKey: uuid.NewString(), Configuration: json.RawMessage(`{"environment":{"type":"openai_hosted"}}`), Initialization: reference} session, err := s.CreateSession(t.Context(), tenant, input) if err != nil { @@ -99,7 +95,7 @@ func TestSoleSkillVersionDeletionRemovesSkill(t *testing.T) { t.Fatal("Skill deletion after sole-version deletion", err) } - // Committed snapshots and Template intent are unchanged, as with DeleteSkill. + // Committed snapshots are unchanged, as with DeleteSkill. after, err := s.ReadEnvironmentSetup(t.Context(), tenant, session.ID) if err != nil || !reflect.DeepEqual(after.Skills, frozen.Skills) { t.Fatal("frozen Session installation changed", err) @@ -108,10 +104,6 @@ func TestSoleSkillVersionDeletionRemovesSkill(t *testing.T) { if err != nil || retry.ID != session.ID { t.Fatal("committed retry read the deleted source", err) } - kept, err := s.GetEnvironmentTemplate(t.Context(), tenant, template.ID) - if err != nil || !reflect.DeepEqual(kept.Skills, template.Skills) || !kept.UpdatedAt.Equal(template.UpdatedAt) { - t.Fatal("Template reference intent changed", err) - } } // Upload and sole-version deletion serialize on the Skill row: an upload that diff --git a/services/core/internal/store/write_audit_resources_test.go b/services/core/internal/store/write_audit_resources_test.go index f30ebf11..7469c621 100644 --- a/services/core/internal/store/write_audit_resources_test.go +++ b/services/core/internal/store/write_audit_resources_test.go @@ -45,7 +45,6 @@ func TestWriteAuditStandaloneResourceTransactions(t *testing.T) { } archive := skillArchive(t, "audit-private-archive") for _, name := range []string{ - "template_create", "template_update", "template_delete", "skill_create", "skill_upload_version", "skill_update_default", "skill_delete", "version_delete", "version_delete_last", } { t.Run(name, func(t *testing.T) { @@ -53,7 +52,7 @@ func TestWriteAuditStandaloneResourceTransactions(t *testing.T) { mutation := prepareResourceAuditMutation(t, s, tenant, name, archive) snapshot := func() map[string]string { result := make(map[string]string) - for _, table := range []string{"agents", "environment_templates", "skills", "skill_versions", "write_audit_operations", "write_audit_owners"} { + for _, table := range []string{"agents", "skills", "skill_versions", "write_audit_operations", "write_audit_owners"} { query := "SELECT COALESCE(jsonb_agg(to_jsonb(r) ORDER BY to_jsonb(r)::text)::text, '[]') FROM " + pgx.Identifier{table}.Sanitize() + " r WHERE tenant_id=$1" var value string if err := pool.QueryRow(ctx, query, tenant).Scan(&value); err != nil { @@ -112,24 +111,6 @@ func prepareResourceAuditMutation(t *testing.T, s *Store, tenant, name string, a t.Fatal(err) } } - if strings.HasPrefix(name, "template_") { - if name == "template_create" { - return resourceAuditMutation{action: "create", kind: "environment_template", owners: 1, run: func(ctx context.Context) (string, error) { - v, e := s.CreateEnvironmentTemplate(ctx, tenant, EnvironmentTemplateInput{}) - return v.ID, e - }} - } - v, err := s.CreateEnvironmentTemplate(ctx, tenant, EnvironmentTemplateInput{}) - must(err) - if name == "template_update" { - return resourceAuditMutation{action: "update", kind: "environment_template", run: func(ctx context.Context) (string, error) { - label := "replacement" - v, e := s.UpdateEnvironmentTemplate(ctx, tenant, v.ID, EnvironmentTemplateInput{SetName: true, Name: &label}) - return v.ID, e - }} - } - return resourceAuditMutation{action: "delete", kind: "environment_template", run: func(ctx context.Context) (string, error) { return s.DeleteEnvironmentTemplate(ctx, tenant, v.ID) }} - } if strings.HasPrefix(name, "skill_") || strings.HasPrefix(name, "version_") { if name == "skill_create" { return resourceAuditMutation{action: "create", kind: "skill", owners: 2, run: func(ctx context.Context) (string, error) { v, e := s.CreateSkill(ctx, tenant, archive); return v.ID, e }}