From 939bb603997eabe6884c07eea7cdd3e69da54689 Mon Sep 17 00:00:00 2001 From: saladday <1203511142@qq.com> Date: Wed, 30 Sep 2026 15:53:49 +0800 Subject: [PATCH 1/5] Move provider configuration semantics behind adapter codecs --- services/agents-api/cmd/server/main.go | 14 +- .../cmd/server/managed_generations.go | 15 +- .../cmd/server/managed_generations_test.go | 28 ++-- .../agents-api/cmd/server/managed_setup.go | 8 +- .../server/managed_setup_preflight_test.go | 18 +-- .../cmd/server/managed_setup_test.go | 8 +- .../cmd/server/runtime_history_test.go | 5 +- .../cmd/specification-contract/main.go | 3 +- services/agents-api/internal/api/errors.go | 30 ++-- services/agents-api/internal/api/handler.go | 70 ++++----- .../internal/api/installation_test.go | 2 +- services/agents-api/internal/api/items.go | 3 +- .../api/sandbox_configuration_discovery.go | 51 +++++++ .../sandbox_configuration_discovery_test.go | 61 ++++++++ .../api/sandbox_deployment_changes_test.go | 6 +- .../internal/api/sandbox_deployment_setup.go | 68 ++++----- .../internal/api/sandbox_e2b_discovery.go | 85 ----------- .../api/sandbox_e2b_discovery_test.go | 58 ------- .../internal/api/sandbox_manager.go | 3 +- .../api/sandbox_node_configuration.go | 3 +- .../internal/api/sandbox_node_detail_test.go | 3 +- .../db/queries/sandbox_deployment_setup.sql | 13 +- .../db/queries/sandbox_generations.sql | 4 +- .../internal/db/queries/sandbox_reset.sql | 3 +- .../agents-api/internal/db/sqlc/models.go | 66 ++++---- .../db/sqlc/runtime_deployment.sql.go | 13 +- .../internal/db/sqlc/runtime_nodes.sql.go | 13 +- .../db/sqlc/sandbox_deployment_setup.sql.go | 65 +++----- .../db/sqlc/sandbox_generations.sql.go | 26 +--- .../internal/db/sqlc/sandbox_reset.sql.go | 18 +-- .../agents-api/internal/engine/mcp_test.go | 3 +- .../archive_cancellation_cleanup_test.go | 8 +- .../environment_capabilities_test.go | 5 +- .../internal/execution/environment_test.go | 3 +- .../execution/runtime_capabilities_test.go | 3 +- .../sandbox_deployment_setup_test.go | 6 +- .../execution/sandbox_generations_test.go | 10 +- .../internal/execution/sandbox_reset_test.go | 5 +- .../execution/sandbox_snapshot_budget_test.go | 5 +- .../runtimeenrollment/connection_test.go | 5 +- .../internal/runtimeobs/operations_test.go | 3 +- .../agents-api/internal/runtimeobs/service.go | 3 +- .../internal/sandbox/configuration.go | 101 +++++++++++++ .../internal/sandbox/configuration_errors.go | 24 +++ .../internal/sandbox/contracttest/provider.go | 5 +- .../internal/sandbox/e2b/configuration.go | 128 ++++++++++++++++ .../sandbox/e2b/configuration_discovery.go | 48 ++++++ .../sandbox/e2b/configuration_types.go | 28 ++++ .../internal/sandbox/e2b/contract_test.go | 3 +- .../internal/sandbox/e2b/credential.go | 17 +-- .../internal/sandbox/e2b/deployment.go | 64 +++----- .../internal/sandbox/e2b/provider.go | 6 +- .../internal/sandbox/e2b/selection_test.go | 8 +- .../sandbox/microsandbox/contract_test.go | 3 +- .../internal/sandbox/microsandbox/process.go | 3 +- .../sandbox/microsandbox/process_test.go | 3 +- .../agents-api/internal/sandbox/node/agent.go | 3 +- .../sandbox/node/observations_test.go | 3 +- .../internal/sandbox/node/operations_test.go | 3 +- .../internal/sandbox/node/recovery_test.go | 7 +- .../agents-api/internal/sandbox/node/wire.go | 3 +- .../agents-api/internal/sandbox/operations.go | 3 +- .../internal/sandbox/operations_test.go | 5 +- .../sandbox/providers/configuration.go | 143 ++++++++++++++++++ .../providers/deployment_contract_test.go | 3 +- .../internal/sandbox/providers/e2b.go | 7 +- .../internal/sandbox/providers/operations.go | 3 +- .../sandbox/providers/operations_test.go | 3 +- .../internal/sandbox/providers/registry.go | 68 +-------- .../sandbox/providers/registry_test.go | 22 +-- .../agents-api/internal/sandbox/selection.go | 33 ++-- .../internal/store/admin_session_archive.go | 3 +- .../admin_session_archive_worker_http_test.go | 4 +- .../store/archive_cancellation_test.go | 5 +- .../store/environment_initialization_test.go | 13 +- .../internal/store/environment_templates.go | 5 +- .../store/environment_templates_test.go | 3 +- .../agents-api/internal/store/export_test.go | 3 +- .../store/function_item_events_test.go | 3 +- .../store/message_input_helpers_test.go | 3 +- .../provider_registration_migration_test.go | 10 +- .../agents-api/internal/store/public_url.go | 2 +- .../store/runtime_initialization_test.go | 3 +- .../store/runtime_node_generations_test.go | 5 +- .../internal/store/runtime_node_types.go | 48 ++---- .../internal/store/runtime_nodes.go | 10 +- .../internal/store/runtime_placements.go | 4 +- .../store/sandbox_deployment_mutations.go | 88 ++++------- .../store/sandbox_deployment_setup.go | 44 +++--- .../store/sandbox_deployment_setup_test.go | 4 +- .../store/sandbox_deployment_switch_test.go | 29 ++-- .../sandbox_deployment_switch_worker_test.go | 4 +- .../store/sandbox_deployment_view_test.go | 19 ++- .../internal/store/sandbox_generations.go | 25 ++- .../store/sandbox_generations_test.go | 47 +++--- .../internal/store/sandbox_reset_test.go | 2 +- .../internal/store/sandbox_specification.go | 2 +- .../store/sandbox_specification_store_test.go | 10 +- .../store/sandbox_specification_test.go | 3 +- .../store/session_model_execution_test.go | 3 +- .../agents-api/internal/store/token_usage.go | 5 +- .../store/token_usage_integration_test.go | 3 +- .../000091_provider_configuration.sql | 117 ++++++++++++++ .../microsandbox-provider/command_test.go | 5 +- .../tools/microsandbox-provider/lock_test.go | 3 +- .../microsandbox-provider/observed_test.go | 3 +- 106 files changed, 1277 insertions(+), 835 deletions(-) create mode 100644 services/agents-api/internal/api/sandbox_configuration_discovery.go create mode 100644 services/agents-api/internal/api/sandbox_configuration_discovery_test.go delete mode 100644 services/agents-api/internal/api/sandbox_e2b_discovery.go delete mode 100644 services/agents-api/internal/api/sandbox_e2b_discovery_test.go create mode 100644 services/agents-api/internal/sandbox/configuration.go create mode 100644 services/agents-api/internal/sandbox/configuration_errors.go create mode 100644 services/agents-api/internal/sandbox/e2b/configuration.go create mode 100644 services/agents-api/internal/sandbox/e2b/configuration_discovery.go create mode 100644 services/agents-api/internal/sandbox/e2b/configuration_types.go create mode 100644 services/agents-api/internal/sandbox/providers/configuration.go create mode 100644 services/agents-api/migrations/000091_provider_configuration.sql diff --git a/services/agents-api/cmd/server/main.go b/services/agents-api/cmd/server/main.go index 95bdb9092..a679c2893 100644 --- a/services/agents-api/cmd/server/main.go +++ b/services/agents-api/cmd/server/main.go @@ -43,7 +43,7 @@ import ( historystoreresolver "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/runtimehistory/storeresolver" "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/runtimeobs" observationstoreresolver "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/runtimeobs/storeresolver" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/sandbox/e2b" + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/sandbox/providers" "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" "github.com/jackc/pgx/v5/pgxpool" ) @@ -186,17 +186,7 @@ func run() error { options := []api.Option{api.WithCoreMetrics(metrics), api.WithSubagents(executionStore), api.WithSkills(executionStore), api.WithSourceFiles(executionStore), api.WithSessionArtifacts(executionStore), api.WithRuntimeObservations(observationService)} if managedNodes != nil { options = append(options, api.WithSandboxManager(executionStore, managedNodes.admin)) - options = append(options, api.WithSandboxE2BDiscovery(func(ctx context.Context, input api.SandboxE2BDiscoveryInput, template string) (api.SandboxE2BDiscoveryResult, error) { - binary := os.Getenv("OAC_E2B_PROVIDER_BIN") - if binary == "" { - binary = "/opt/oac/e2b/oac-e2b-provider" - } - out, err := e2b.Discover(ctx, &e2b.ProcessCaller{}, binary, input.APIKey, input.APIURL, input.Domain, template) - if err != nil { - return api.SandboxE2BDiscoveryResult{}, err - } - return api.SandboxE2BDiscoveryResult{Templates: out.Templates, Builds: out.Builds}, nil - })) + options = append(options, api.WithSandboxConfigurationDiscovery(providers.DiscoverConfiguration)) } var keyAdmin *api.DeploymentAuthenticator if managedNodes != nil { diff --git a/services/agents-api/cmd/server/managed_generations.go b/services/agents-api/cmd/server/managed_generations.go index 23a265f06..71fecd2d8 100644 --- a/services/agents-api/cmd/server/managed_generations.go +++ b/services/agents-api/cmd/server/managed_generations.go @@ -3,10 +3,11 @@ package main import ( "context" "errors" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/providercontract" "maps" "time" + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/providercontract" + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/execution" "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/runtimeobs" "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/sandbox" @@ -130,13 +131,13 @@ func (s *managedSetup) routeGenerations(candidate execution.PreparedRuntimeDeplo if err := sandbox.ValidateProvider(candidate.Config.Provider); err != nil { return execution.PreparedRuntimeDeployment{}, err } - if !adapter.Credential { + if adapter.Configuration.Requirements().Credential != sandbox.Required { return candidate, nil } candidate.FenceCredential = func(ctx context.Context) (func(), error) { release, err := s.providerCalls.Fence(ctx) if err != nil { - return nil, adapter.CredentialUnconfirmed + return nil, sandbox.ErrConfigurationUnconfirmed } return release, nil } @@ -168,7 +169,7 @@ func (s *managedSetup) routeGenerations(candidate execution.PreparedRuntimeDeplo return &store.SandboxResetRequiredError{CurrentProvider: current.Provider, RequestedProvider: setup.Provider} } if err := verify(current, nil); err != nil { - if adapter.CredentialRequiresReset != nil && adapter.CredentialRequiresReset(err) { + if errors.Is(err, sandbox.ErrCredentialRejected) || errors.Is(err, sandbox.ErrCredentialOwnership) { // A revoked legacy key or a public template outside its team cannot // anchor ownership. This says nothing about the candidate key's validity. return &store.SandboxResetRequiredError{CurrentProvider: setup.Provider, RequestedProvider: setup.Provider} @@ -176,11 +177,11 @@ func (s *managedSetup) routeGenerations(candidate execution.PreparedRuntimeDeplo return err } withCandidateKey := func(value store.SandboxSetup, refs []sandbox.Reference) error { - selection, err := providers.WithCredential(sandbox.Selection{Provider: value.Provider, DeploymentSpec: value.Specification, E2B: value.E2B}, sandbox.Selection{Provider: setup.Provider, DeploymentSpec: setup.Specification, E2B: setup.E2B}) + selection, err := providers.WithCredential(sandbox.Selection{Provider: value.Provider, DeploymentSpec: value.Specification, Configuration: value.Configuration}, sandbox.Selection{Provider: setup.Provider, DeploymentSpec: setup.Specification, Configuration: setup.Configuration}) if err != nil { return err } - value.E2B = selection.E2B + value.Configuration = selection.Configuration return verify(value, refs) } if err := withCandidateKey(current, nil); err != nil { @@ -219,7 +220,7 @@ func (s *managedSetup) routeGenerations(candidate execution.PreparedRuntimeDeplo for generation, refs := range refsByGeneration { owner, ok := generations[generation] if !ok { - return adapter.CredentialUnconfirmed + return sandbox.ErrConfigurationUnconfirmed } if err := withCandidateKey(owner, refs); err != nil { return err diff --git a/services/agents-api/cmd/server/managed_generations_test.go b/services/agents-api/cmd/server/managed_generations_test.go index 4d9190d0b..1e0f90d28 100644 --- a/services/agents-api/cmd/server/managed_generations_test.go +++ b/services/agents-api/cmd/server/managed_generations_test.go @@ -26,9 +26,9 @@ func (s *routingSetupStore) GetSandboxAllocationSetup(_ context.Context, ref san value := s.value if ref.AllocationID == s.oldID { value = s.old - key := *value.E2B - key.APIKey = s.value.E2B.APIKey - value.E2B = &key + key := *value.Configuration.(*e2b.DeploymentConfiguration) + key.APIKey = s.value.Configuration.(*e2b.DeploymentConfiguration).APIKey + value.Configuration = &key } return value, nil } @@ -52,11 +52,11 @@ print(json.dumps({'Version':1,'Info':info})) t.Setenv("OAC_E2B_PROVIDER_BIN", helper) t.Setenv("OAC_E2B_STATE_DIR", state) id := uuid.NewString() - old := store.SandboxSetup{InstallationID: id, Provider: "e2b", Mode: "direct", Generation: 1, Specification: sandbox.DeploymentSpec{Resources: sandbox.Resources{CPUs: 2, MemoryMiB: 2048}}, E2B: &sandbox.E2BConfiguration{APIKey: "old-key", Template: "old:" + uuid.NewString()}} + old := store.SandboxSetup{InstallationID: id, Provider: "e2b", Mode: "direct", Generation: 1, Specification: sandbox.DeploymentSpec{Resources: sandbox.Resources{CPUs: 2, MemoryMiB: 2048}}, Configuration: &e2b.DeploymentConfiguration{APIKey: "old-key", Template: "old:" + uuid.NewString()}} current := old current.Generation = 2 current.Specification.Resources.CPUs = 4 - current.E2B = &sandbox.E2BConfiguration{APIKey: "new-key", Template: "new:" + uuid.NewString()} + current.Configuration = &e2b.DeploymentConfiguration{APIKey: "new-key", Template: "new:" + uuid.NewString()} ref := sandbox.Reference{TenantID: uuid.NewString(), EnvironmentID: uuid.NewString(), AllocationID: uuid.NewString()} db := &routingSetupStore{setupStore: setupStore{value: current}, old: old, oldID: ref.AllocationID} setup := &managedSetup{store: db, installationID: id} @@ -100,7 +100,7 @@ print(json.dumps({'Version':1,'Info':info})) if i == 3 { expected = current } - if q.Config.APIKey != "new-key" || q.Config.Template != expected.E2B.Template || q.Config.Resources != expected.Specification.Resources { + if q.Config.APIKey != "new-key" || q.Config.Template != expected.Configuration.(*e2b.DeploymentConfiguration).Template || q.Config.Resources != expected.Specification.Resources { t.Fatal("generation or credential mismatch", i) } } @@ -116,8 +116,8 @@ func TestE2BReplacementRequiresCommittedOwnershipAnchor(t *testing.T) { }{ {name: "legacy public template cross team", committedKey: "team-a", committedTemplate: "public-b", candidateKey: "team-b", candidateTemplate: "public-b", reset: true}, {name: "revoked committed key", committedKey: "revoked", committedTemplate: "owned-a", candidateKey: "team-a", candidateTemplate: "owned-a", reset: true}, - {name: "unknown committed ownership", committedKey: "unconfirmed", committedTemplate: "owned-a", candidateKey: "team-a", candidateTemplate: "owned-a", want: e2b.ErrRequestUnconfirmed}, - {name: "proven different team", committedKey: "team-a", committedTemplate: "owned-a", candidateKey: "team-b", candidateTemplate: "public-b", want: e2b.ErrTeamMismatch}, + {name: "unknown committed ownership", committedKey: "unconfirmed", committedTemplate: "owned-a", candidateKey: "team-a", candidateTemplate: "owned-a", want: sandbox.ErrConfigurationUnconfirmed}, + {name: "proven different team", committedKey: "team-a", committedTemplate: "owned-a", candidateKey: "team-b", candidateTemplate: "public-b", want: sandbox.ErrCredentialOwnership}, {name: "same team replacement", committedKey: "team-a", committedTemplate: "owned-a", candidateKey: "team-a-rotated", candidateTemplate: "new-a"}, {name: "explicit same key", committedKey: "team-a", committedTemplate: "owned-a", candidateKey: "team-a", candidateTemplate: "owned-a"}, } { @@ -152,7 +152,7 @@ print(json.dumps(result)) id, build := uuid.NewString(), ":"+uuid.NewString() current := store.SandboxSetup{InstallationID: id, Provider: "e2b", Mode: "direct", Generation: 1, Specification: sandbox.DeploymentSpec{Resources: sandbox.Resources{CPUs: 2, MemoryMiB: 2048}}, - E2B: &sandbox.E2BConfiguration{APIKey: tc.committedKey, Template: tc.committedTemplate + build}} + Configuration: &e2b.DeploymentConfiguration{APIKey: tc.committedKey, Template: tc.committedTemplate + build}} db := &setupStore{value: current} s := &managedSetup{installationID: id, store: db} loaded, err := s.load(t.Context()) @@ -160,7 +160,7 @@ print(json.dumps(result)) t.Fatal(err) } next := current - next.E2B = &sandbox.E2BConfiguration{APIKey: tc.candidateKey, Template: tc.candidateTemplate + build} + next.Configuration = &e2b.DeploymentConfiguration{APIKey: tc.candidateKey, Template: tc.candidateTemplate + build} candidate, err := s.prepare(t.Context(), next) if err != nil { t.Fatal(err) @@ -168,13 +168,13 @@ print(json.dumps(result)) err = candidate.VerifyCredential(t.Context()) var reset *store.SandboxResetRequiredError if tc.reset { - if !errors.As(err, &reset) || errors.Is(err, e2b.ErrCredentialInvalid) || errors.Is(err, e2b.ErrTeamMismatch) { + if !errors.As(err, &reset) || errors.Is(err, sandbox.ErrCredentialRejected) || errors.Is(err, sandbox.ErrCredentialOwnership) { t.Fatalf("unanchored ownership misattributed: %v", err) } } else if !errors.Is(err, tc.want) { t.Fatalf("got %v; want %v", err, tc.want) } - if db.value.Generation != 1 || db.value.E2B.APIKey != tc.committedKey || s.selected.Load().Config != loaded { + if db.value.Generation != 1 || db.value.Configuration.(*e2b.DeploymentConfiguration).APIKey != tc.committedKey || s.selected.Load().Config != loaded { t.Fatal("verification mutated committed selection") } raw, err := os.ReadFile(filepath.Join(state, "requests")) @@ -192,10 +192,10 @@ print(json.dumps(result)) t.Fatal("verification mutated provider", q.Operation) } } - if len(requests) < 2 || requests[1].Config.APIKey != tc.committedKey || requests[1].Config.Template != current.E2B.Template { + if len(requests) < 2 || requests[1].Config.APIKey != tc.committedKey || requests[1].Config.Template != current.Configuration.(*e2b.DeploymentConfiguration).Template { t.Fatal("committed key was replaced before establishing ownership") } - if tc.reset || tc.want == e2b.ErrRequestUnconfirmed { + if tc.reset || tc.want == sandbox.ErrConfigurationUnconfirmed { if len(requests) != 2 { t.Fatal("unanchored current ownership reached candidate verification") } diff --git a/services/agents-api/cmd/server/managed_setup.go b/services/agents-api/cmd/server/managed_setup.go index 1167b664d..8a8339ec7 100644 --- a/services/agents-api/cmd/server/managed_setup.go +++ b/services/agents-api/cmd/server/managed_setup.go @@ -87,21 +87,21 @@ func (s *managedSetup) prepare(ctx context.Context, setup store.SandboxSetup) (e if err != nil { return execution.PreparedRuntimeDeployment{}, err } - if adapter.PublicOrigin && store.LoopbackOrigin(s.publicURL) { + if adapter.Configuration.Requirements().PublicOrigin == sandbox.Required && store.LoopbackOrigin(s.publicURL) { return execution.PreparedRuntimeDeployment{}, store.ErrSandboxPublicURLUnreachable } candidate, err := s.configuration(setup) if err != nil { return execution.PreparedRuntimeDeployment{}, err } - selection := sandbox.Selection{Provider: setup.Provider, DeploymentSpec: setup.Specification, E2B: setup.E2B} + selection := sandbox.Selection{Provider: setup.Provider, DeploymentSpec: setup.Specification, Configuration: setup.Configuration} if err := providercontract.Require(candidate.Config.Provider, "DiscoverSelection"); err == nil { discoverer := candidate.Config.Provider.(sandbox.SelectionDiscoverer) selection, err = discoverer.DiscoverSelection(ctx, selection) if err != nil { return execution.PreparedRuntimeDeployment{}, err } - setup.Specification, setup.E2B = selection.DeploymentSpec, selection.E2B + setup.Specification, setup.Configuration = selection.DeploymentSpec, selection.Configuration candidate, err = s.configuration(setup) if err != nil { return execution.PreparedRuntimeDeployment{}, err @@ -191,5 +191,5 @@ func (s *managedSetup) provider(setup store.SandboxSetup) (sandbox.SandboxProvid } return s.hub.GenerationProvider(setup.Provider, s.store.ResolveRuntimeGeneration), nil } - return providers.BuildDirect(providers.DirectConfig{InstallationID: setup.InstallationID, Selection: sandbox.Selection{Provider: setup.Provider, DeploymentSpec: setup.Specification, E2B: setup.E2B}, Fence: &s.providerCalls}) + return providers.BuildDirect(providers.DirectConfig{InstallationID: setup.InstallationID, Selection: sandbox.Selection{Provider: setup.Provider, DeploymentSpec: setup.Specification, Configuration: setup.Configuration}, Fence: &s.providerCalls}) } diff --git a/services/agents-api/cmd/server/managed_setup_preflight_test.go b/services/agents-api/cmd/server/managed_setup_preflight_test.go index a818e65a4..20f9e5f8f 100644 --- a/services/agents-api/cmd/server/managed_setup_preflight_test.go +++ b/services/agents-api/cmd/server/managed_setup_preflight_test.go @@ -28,9 +28,9 @@ func TestE2BRejectedSpecificationHasSafeActionableDiagnostic(t *testing.T) { t.Setenv("OAC_E2B_STATE_DIR", state) id := uuid.NewString() s := &managedSetup{installationID: id} - selection := store.SandboxSetup{InstallationID: id, Provider: "e2b", Specification: sandbox.DeploymentSpec{Resources: sandbox.Resources{CPUs: 3, MemoryMiB: 3072}}, E2B: &sandbox.E2BConfiguration{APIKey: "synthetic-private-key", Template: "runtime:" + uuid.NewString()}} + selection := store.SandboxSetup{InstallationID: id, Provider: "e2b", Specification: sandbox.DeploymentSpec{Resources: sandbox.Resources{CPUs: 3, MemoryMiB: 3072}}, Configuration: &e2b.DeploymentConfiguration{APIKey: "synthetic-private-key", Template: "runtime:" + uuid.NewString()}} _, err := s.prepare(t.Context(), selection) - if !errors.Is(err, e2b.ErrTemplateInvalid) || strings.Contains(err.Error(), "synthetic-private-key") || s.selected.Load() != nil { + if !errors.Is(err, sandbox.ErrConfigurationSelection) || strings.Contains(err.Error(), "synthetic-private-key") || s.selected.Load() != nil { t.Fatal("rejected candidate lost its safe diagnostic or was published", err) } s.store = &setupStore{value: selection} @@ -67,7 +67,7 @@ else: id := uuid.NewString() selection := store.SandboxSetup{InstallationID: id, Provider: "e2b", Generation: 1, Specification: sandbox.DeploymentSpec{Resources: sandbox.Resources{CPUs: 2, MemoryMiB: 2048}}, - E2B: &sandbox.E2BConfiguration{APIKey: "synthetic-private-key", Template: "runtime:" + uuid.NewString()}} + Configuration: &e2b.DeploymentConfiguration{APIKey: "synthetic-private-key", Template: "runtime:" + uuid.NewString()}} s := &managedSetup{installationID: id, store: &setupStore{value: selection}} if _, err := s.prepare(t.Context(), selection); err == nil || s.selected.Load() != nil { t.Fatal("invalid new template selection was published", err) @@ -107,12 +107,12 @@ func TestE2BCandidateAdoptsTemplateBuildForOmittedResources(t *testing.T) { t.Setenv("OAC_E2B_STATE_DIR", state) id := uuid.NewString() s := &managedSetup{installationID: id, store: &setupStore{}} - selection := store.SandboxSetup{InstallationID: id, Provider: "e2b", E2B: &sandbox.E2BConfiguration{APIKey: "synthetic-private-key", Template: "runtime:" + uuid.NewString()}} + selection := store.SandboxSetup{InstallationID: id, Provider: "e2b", Configuration: &e2b.DeploymentConfiguration{APIKey: "synthetic-private-key", Template: "runtime:" + uuid.NewString()}} candidate, err := s.prepare(t.Context(), selection) disk := int32(24063) - if err != nil || candidate.Selection.E2B.TemplateBuild == nil || candidate.Selection.E2B.TemplateBuild.CPUs != 4 || candidate.Selection.E2B.TemplateBuild.MemoryMiB != 4096 || - *candidate.Selection.E2B.TemplateBuild.RootDiskMiB != disk || candidate.Selection.E2B.TemplateBuild.Status != "ready" { - t.Fatalf("validated build was not recorded: %+v %v", candidate.Selection.E2B.TemplateBuild, err) + if err != nil || candidate.Selection.Configuration.(*e2b.DeploymentConfiguration).TemplateBuild == nil || candidate.Selection.Configuration.(*e2b.DeploymentConfiguration).TemplateBuild.CPUs != 4 || candidate.Selection.Configuration.(*e2b.DeploymentConfiguration).TemplateBuild.MemoryMiB != 4096 || + *candidate.Selection.Configuration.(*e2b.DeploymentConfiguration).TemplateBuild.RootDiskMiB != disk || candidate.Selection.Configuration.(*e2b.DeploymentConfiguration).TemplateBuild.Status != "ready" { + t.Fatalf("validated build was not recorded: %+v %v", candidate.Selection.Configuration.(*e2b.DeploymentConfiguration).TemplateBuild, err) } // The published candidate enforces the adopted resources. selection.Specification.Resources = sandbox.Resources{CPUs: 4, MemoryMiB: 4096} @@ -143,8 +143,8 @@ func TestInitialE2BPublicTemplateOutsideTeamIsRejected(t *testing.T) { t.Setenv("OAC_E2B_STATE_DIR", state) id := uuid.NewString() s := &managedSetup{installationID: id, store: &setupStore{}} - selection := store.SandboxSetup{InstallationID: id, Provider: "e2b", E2B: &sandbox.E2BConfiguration{APIKey: "synthetic-team-a", Template: "public-team-b:" + uuid.NewString()}} - if _, err := s.prepare(t.Context(), selection); !errors.Is(err, e2b.ErrTeamMismatch) || s.selected.Load() != nil { + selection := store.SandboxSetup{InstallationID: id, Provider: "e2b", Configuration: &e2b.DeploymentConfiguration{APIKey: "synthetic-team-a", Template: "public-team-b:" + uuid.NewString()}} + if _, err := s.prepare(t.Context(), selection); !errors.Is(err, sandbox.ErrCredentialOwnership) || s.selected.Load() != nil { t.Fatal("public readability accepted as team ownership", err) } } diff --git a/services/agents-api/cmd/server/managed_setup_test.go b/services/agents-api/cmd/server/managed_setup_test.go index 0c42c3c7b..75e08430e 100644 --- a/services/agents-api/cmd/server/managed_setup_test.go +++ b/services/agents-api/cmd/server/managed_setup_test.go @@ -10,6 +10,8 @@ import ( "strings" "testing" + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/sandbox/e2b" + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/execution" "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/sandbox" "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/sandbox/node" @@ -86,7 +88,7 @@ func TestMissingE2BHelperReportsProviderUnavailable(t *testing.T) { t.Setenv("OAC_E2B_STATE_DIR", t.TempDir()) s := &managedSetup{installationID: id, store: &setupStore{value: store.SandboxSetup{ InstallationID: id, Provider: "e2b", Mode: "direct", Generation: 1, - E2B: &sandbox.E2BConfiguration{APIKey: "synthetic-key", Template: "runtime:" + uuid.NewString()}, + Configuration: &e2b.DeploymentConfiguration{APIKey: "synthetic-key", Template: "runtime:" + uuid.NewString()}, }}} if _, err := s.load(t.Context()); !errors.Is(err, execution.ErrExecutionUnavailable) { t.Fatal("missing local helper must leave administrative recovery available", err) @@ -126,7 +128,7 @@ func TestManagedSetupRejectedCandidateRetainsSelection(t *testing.T) { previous := &execution.RuntimeProvider{InstallationID: id, Generation: 1, ProviderKind: "docker"} s.publish(previous) _, err := s.prepare(t.Context(), store.SandboxSetup{InstallationID: id, Provider: "e2b", Mode: "direct", - E2B: &sandbox.E2BConfiguration{APIKey: "synthetic-key", Template: "runtime:" + uuid.NewString()}}) + Configuration: &e2b.DeploymentConfiguration{APIKey: "synthetic-key", Template: "runtime:" + uuid.NewString()}}) if !errors.Is(err, execution.ErrExecutionUnavailable) || s.selected.Load().Config != previous { t.Fatal("rejected candidate lost the previous selection", err) } @@ -136,7 +138,7 @@ func TestE2BRequiresAPublicURLOutsideTheHost(t *testing.T) { id := uuid.NewString() s := &managedSetup{installationID: id, publicURL: "http://127.0.0.1:8091"} _, err := s.prepare(t.Context(), store.SandboxSetup{InstallationID: id, Provider: "e2b", Mode: "direct", - E2B: &sandbox.E2BConfiguration{APIKey: "synthetic-key", Template: "runtime:" + uuid.NewString()}}) + Configuration: &e2b.DeploymentConfiguration{APIKey: "synthetic-key", Template: "runtime:" + uuid.NewString()}}) if !errors.Is(err, store.ErrSandboxPublicURLUnreachable) { t.Fatal("E2B accepted a loopback public URL", err) } diff --git a/services/agents-api/cmd/server/runtime_history_test.go b/services/agents-api/cmd/server/runtime_history_test.go index 52cba81ad..4c1d04ade 100644 --- a/services/agents-api/cmd/server/runtime_history_test.go +++ b/services/agents-api/cmd/server/runtime_history_test.go @@ -2,13 +2,14 @@ package main import ( "context" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/runtimeobs" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" "os" "path/filepath" "strings" "testing" "time" + + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/runtimeobs" + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" ) type panicHistoryExporter struct{} diff --git a/services/agents-api/cmd/specification-contract/main.go b/services/agents-api/cmd/specification-contract/main.go index 67f445eac..96a75fb11 100644 --- a/services/agents-api/cmd/specification-contract/main.go +++ b/services/agents-api/cmd/specification-contract/main.go @@ -4,9 +4,10 @@ package main import ( "flag" "fmt" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/sandbox/providers" "os" "strings" + + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/sandbox/providers" ) func main() { diff --git a/services/agents-api/internal/api/errors.go b/services/agents-api/internal/api/errors.go index a2530596e..56be987a7 100644 --- a/services/agents-api/internal/api/errors.go +++ b/services/agents-api/internal/api/errors.go @@ -9,8 +9,8 @@ import ( v1 "github.com/MiniMax-AI-Dev/parsar/contracts/agents-api/v1" "github.com/MiniMax-AI-Dev/parsar/internal/obs/log" "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/execution" + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/providercontract" "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/sandbox" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/sandbox/e2b" "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" ) @@ -102,6 +102,8 @@ func writeStoreError(w http.ResponseWriter, r *http.Request, err error, notFound if writeCoreValidationError(w, err) { return } + var configuration *sandbox.ConfigurationError + var unsupported *providercontract.UnsupportedError var cursor *store.InvalidCursorError var selection *store.MCPCredentialSelectionError var sandboxConfiguration *store.SandboxConfigurationError @@ -109,14 +111,24 @@ func writeStoreError(w http.ResponseWriter, r *http.Request, err error, notFound var resetRequired *store.SandboxResetRequiredError var inUse *store.SandboxInUseError switch { - case errors.Is(err, e2b.ErrRequestUnconfirmed): - writeError(w, http.StatusServiceUnavailable, "e2b_request_unconfirmed", "E2B verification could not be confirmed.") - case errors.Is(err, e2b.ErrTemplateInvalid): - writeError(w, http.StatusBadRequest, "e2b_template_build_invalid", "Select a ready immutable E2B template build with matching resources.", "e2b.template") - case errors.Is(err, e2b.ErrCredentialInvalid): - writeError(w, http.StatusBadRequest, "e2b_api_key_invalid", "The E2B API key was rejected.", "e2b.api_key") - case errors.Is(err, e2b.ErrTeamMismatch): - writeError(w, http.StatusConflict, "e2b_team_mismatch", "The E2B key cannot manage the retained deployment. Reset before changing teams.", "e2b.api_key") + case errors.As(err, &configuration): + status := http.StatusInternalServerError + switch configuration.Class { + case sandbox.ConfigurationInvalid: + status = http.StatusBadRequest + case sandbox.ConfigurationConflict: + status = http.StatusConflict + case sandbox.ConfigurationUnconfirmed: + status = http.StatusServiceUnavailable + default: + writeError(w, status, "internal_error", "The operation could not be completed.") + return + } + writeError(w, status, configuration.Code, configuration.Message, configuration.Param) + case errors.As(err, &unsupported): + writeError(w, http.StatusBadRequest, "sandbox_operation_unsupported", "The selected sandbox provider does not support this operation.") + case errors.Is(err, sandbox.ErrInvalid): + writeError(w, http.StatusBadRequest, "invalid_sandbox_configuration", "Invalid sandbox provider configuration.", "configuration") case errors.As(err, &stale): writeCoreError(w, http.StatusConflict, "sandbox_generation_stale", "The sandbox deployment generation changed. Refresh before submitting again.", CoreErrorDetails{"current_generation": CoreErrorNumber(float64(stale.CurrentGeneration))}) case errors.As(err, &resetRequired): diff --git a/services/agents-api/internal/api/handler.go b/services/agents-api/internal/api/handler.go index 13edad72c..8b7fed23b 100644 --- a/services/agents-api/internal/api/handler.go +++ b/services/agents-api/internal/api/handler.go @@ -9,6 +9,8 @@ import ( "net/http" "reflect" + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/sandbox" + v1 "github.com/MiniMax-AI-Dev/parsar/contracts/agents-api/v1" "github.com/MiniMax-AI-Dev/parsar/internal/obs/log" "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/execution" @@ -38,40 +40,40 @@ type ResourceStore interface { } type Handler struct { - nativeInstaller *nativeinstaller.Catalog - nativeVersion string - executorConnections func(context.Context, string, string) (bool, error) - coreMetrics CoreMetricsService - sandboxStore *store.Store - deploymentAuth *DeploymentAuthenticator - sandboxSetup func(context.Context, store.SandboxDeploymentSetupRequest) (store.RuntimeDeploymentView, error) - sandboxE2BDiscover func(context.Context, SandboxE2BDiscoveryInput, string) (SandboxE2BDiscoveryResult, error) - sandboxUpdate func(context.Context, store.SandboxDeploymentUpdateRequest) (store.RuntimeDeploymentView, error) - sandboxReset func(context.Context, store.SandboxResetRequest) (store.RuntimeDeploymentView, error) - sandboxResetCancel func(context.Context, uint64) (store.RuntimeDeploymentView, error) - policy execution.Policy - store ResourceStore - auth *Authenticator - projectKeys ProjectAPIKeyStore - writeAudit WriteAuditStore - adminArchive func(context.Context, string, string, uint64) (store.ManagedSessionArchive, error) - adminManagement AdminManagementStore - harnesses map[string]bool - modelProviderDefaults ModelProviderDefaults - engine string - inputs InputSubmitter - executorURL string - hostedEnvironments bool - directoryReader EnvironmentDirectoryReader - fileWriter EnvironmentFileWriter - skills SkillStore - sourceFiles SourceFileStore - artifacts SessionArtifactStore - subagents SubagentStore - runtimeObservations RuntimeObservationService - runtimeHistory RuntimeHistoryService - installation *Installation - installationBindings func(context.Context) (store.AddressBindings, error) + nativeInstaller *nativeinstaller.Catalog + nativeVersion string + executorConnections func(context.Context, string, string) (bool, error) + coreMetrics CoreMetricsService + sandboxStore *store.Store + deploymentAuth *DeploymentAuthenticator + sandboxSetup func(context.Context, store.SandboxDeploymentSetupRequest) (store.RuntimeDeploymentView, error) + sandboxConfigurationDiscover func(context.Context, string, sandbox.ConfigurationDiscoveryInput) (json.RawMessage, error) + sandboxUpdate func(context.Context, store.SandboxDeploymentUpdateRequest) (store.RuntimeDeploymentView, error) + sandboxReset func(context.Context, store.SandboxResetRequest) (store.RuntimeDeploymentView, error) + sandboxResetCancel func(context.Context, uint64) (store.RuntimeDeploymentView, error) + policy execution.Policy + store ResourceStore + auth *Authenticator + projectKeys ProjectAPIKeyStore + writeAudit WriteAuditStore + adminArchive func(context.Context, string, string, uint64) (store.ManagedSessionArchive, error) + adminManagement AdminManagementStore + harnesses map[string]bool + modelProviderDefaults ModelProviderDefaults + engine string + inputs InputSubmitter + executorURL string + hostedEnvironments bool + directoryReader EnvironmentDirectoryReader + fileWriter EnvironmentFileWriter + skills SkillStore + sourceFiles SourceFileStore + artifacts SessionArtifactStore + subagents SubagentStore + runtimeObservations RuntimeObservationService + runtimeHistory RuntimeHistoryService + installation *Installation + installationBindings func(context.Context) (store.AddressBindings, error) } func NewHandler(s ResourceStore, auth *Authenticator, engine string, options ...Option) (http.Handler, error) { diff --git a/services/agents-api/internal/api/installation_test.go b/services/agents-api/internal/api/installation_test.go index e8fa97d69..25ef8dce0 100644 --- a/services/agents-api/internal/api/installation_test.go +++ b/services/agents-api/internal/api/installation_test.go @@ -89,7 +89,7 @@ func TestDeploymentAddressIsNotInput(t *testing.T) { }{ {http.MethodPost, `{"provider":"docker","core_url":"https://core.example","expected_generation":0}`, `"param":"core_url"`, http.StatusBadRequest}, {http.MethodPut, `{"provider":"docker","core_url":"https://core.example","expected_generation":1}`, `"param":"core_url"`, http.StatusBadRequest}, - {http.MethodPost, `{"provider":"e2b","expected_generation":0,"e2b":{"api_key":"key","template":"runtime:build"}}`, `"code":"sandbox_configuration_error"`, http.StatusConflict}, + {http.MethodPost, `{"provider":"e2b","expected_generation":0,"credential":{"api_key":"key"},"configuration":{"template":"runtime:build"}}`, `"code":"sandbox_configuration_error"`, http.StatusConflict}, } { request := httptest.NewRequest(test.method, "/core/v1/sandbox/deployment", strings.NewReader(test.body)) request.Header.Set("Authorization", "Bearer administrator") diff --git a/services/agents-api/internal/api/items.go b/services/agents-api/internal/api/items.go index 119ad16a0..05d099c62 100644 --- a/services/agents-api/internal/api/items.go +++ b/services/agents-api/internal/api/items.go @@ -1,8 +1,9 @@ package api import ( - "github.com/go-chi/chi/v5" "net/http" + + "github.com/go-chi/chi/v5" ) // @Summary List persisted execution Items diff --git a/services/agents-api/internal/api/sandbox_configuration_discovery.go b/services/agents-api/internal/api/sandbox_configuration_discovery.go new file mode 100644 index 000000000..a25416470 --- /dev/null +++ b/services/agents-api/internal/api/sandbox_configuration_discovery.go @@ -0,0 +1,51 @@ +package api + +import ( + "context" + "encoding/json" + "net/http" + "time" + + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/sandbox" + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" + "github.com/go-chi/chi/v5" +) + +func WithSandboxConfigurationDiscovery(discover func(context.Context, string, sandbox.ConfigurationDiscoveryInput) (json.RawMessage, error)) Option { + return func(h *Handler) { h.sandboxConfigurationDiscover = discover } +} + +// @Summary Discover sandbox provider configuration +// @Description Core key only. Uses transient write-only credentials. The provider validates configuration and query fields and returns safe catalog metadata. Does not save credentials, change a deployment or allocate compute. Discovery is not deployment admission. +// @Tags Sandbox Manager +// @Accept json +// @Produce json +// @Security DeploymentAdminAuth +// @Param provider path string true "Registered provider kind" +// @Param body body sandbox.ConfigurationDiscoveryInput true "Transient provider connection and query" +// @Success 200 {object} map[string]interface{} +// @Failure 400,401,500,503 {object} CoreErrorResponse +// @Router /core/v1/sandbox/providers/{provider}/discovery [post] +func (h *Handler) discoverSandboxConfiguration(w http.ResponseWriter, r *http.Request) { + raw, ok := readJSONBodyLimit(w, r, 64*1024, "Configuration discovery request is too large.") + if !ok { + return + } + var input sandbox.ConfigurationDiscoveryInput + if decodeInputObject(raw, &input, "configuration", "credential", "query") != nil { + writeStoreError(w, r, store.ErrInvalidInput) + return + } + if h.sandboxConfigurationDiscover == nil { + writeStoreError(w, r, store.ErrSandboxDeploymentConflict) + return + } + ctx, cancel := context.WithTimeout(r.Context(), 30*time.Second) + defer cancel() + result, err := h.sandboxConfigurationDiscover(ctx, chi.URLParam(r, "provider"), input) + if err != nil { + writeStoreError(w, r, err) + return + } + writeJSON(w, http.StatusOK, result) +} diff --git a/services/agents-api/internal/api/sandbox_configuration_discovery_test.go b/services/agents-api/internal/api/sandbox_configuration_discovery_test.go new file mode 100644 index 000000000..d7dae9b51 --- /dev/null +++ b/services/agents-api/internal/api/sandbox_configuration_discovery_test.go @@ -0,0 +1,61 @@ +package api + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/device" + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/sandbox" + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" +) + +func TestSandboxE2BDiscoveryAuthenticationAndCredentialPrivacy(t *testing.T) { + project, _ := NewAuthenticator([]APIKey{callerBinding()}) + admin, _ := NewDeploymentAuthenticator([]string{device.HashCredential("administrator")}) + calls := 0 + discover := func(ctx context.Context, kind string, input sandbox.ConfigurationDiscoveryInput) (json.RawMessage, error) { + calls++ + if kind != "e2b" || !strings.Contains(string(input.Credential), "private-test-key") { + t.Fatal("wrong request") + } + if _, ok := ctx.Deadline(); !ok { + t.Fatal("unbounded discovery") + } + if strings.Contains(string(input.Query), "bad") { + return nil, sandbox.ErrConfigurationUnconfirmed + } + if strings.Contains(string(input.Query), "tpl_123") { + return json.RawMessage(`{"builds":[]}`), nil + } + return json.RawMessage(`{"templates":[]}`), nil + } + h, err := NewHandler(&recordingStore{}, project, "codex", WithSandboxManager(&store.Store{}, admin), WithSandboxConfigurationDiscovery(discover)) + if err != nil { + t.Fatal(err) + } + for _, tc := range []struct { + token, path, body string + status int + }{ + {"caller", "/core/v1/sandbox/providers/e2b/discovery", `{"credential":{"api_key":"private-test-key"},"query":{}}`, 401}, + {"administrator", "/core/v1/sandbox/providers/e2b/discovery", `{"credential":{"api_key":"private-test-key"},"unexpected":true}`, 400}, + {"administrator", "/core/v1/sandbox/providers/e2b/discovery", `{"credential":{"api_key":"private-test-key"},"query":{}}`, 200}, + {"administrator", "/core/v1/sandbox/providers/e2b/discovery", `{"credential":{"api_key":"private-test-key"},"query":{"template":"tpl_123"}}`, 200}, + {"administrator", "/core/v1/sandbox/providers/e2b/discovery", `{"credential":{"api_key":"private-test-key"},"query":{"template":"bad"}}`, 503}, + } { + req := httptest.NewRequest(http.MethodPost, tc.path, strings.NewReader(tc.body)) + req.Header.Set("Authorization", "Bearer "+tc.token) + result := httptest.NewRecorder() + h.ServeHTTP(result, req) + if result.Code != tc.status || strings.Contains(result.Body.String(), "private-test-key") { + t.Fatal(result.Code, result.Body.String()) + } + } + if calls != 3 { + t.Fatal(calls) + } +} diff --git a/services/agents-api/internal/api/sandbox_deployment_changes_test.go b/services/agents-api/internal/api/sandbox_deployment_changes_test.go index 5227157a1..737799129 100644 --- a/services/agents-api/internal/api/sandbox_deployment_changes_test.go +++ b/services/agents-api/internal/api/sandbox_deployment_changes_test.go @@ -7,6 +7,8 @@ import ( "strings" "testing" + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/sandbox/e2b" + "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/device" "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" ) @@ -17,7 +19,7 @@ func TestSandboxDeploymentChangesAuthenticateAndDecode(t *testing.T) { updates, resets := 0, 0 update := func(_ context.Context, in store.SandboxDeploymentUpdateRequest) (store.RuntimeDeploymentView, error) { updates++ - if in.Provider != "e2b" || in.ExpectedGeneration != 2 || in.E2B == nil || in.E2B.APIKey != "synthetic-private-key" { + if in.Provider != "e2b" || in.ExpectedGeneration != 2 || in.Configuration == nil || in.Configuration.(*e2b.DeploymentConfiguration).APIKey != "synthetic-private-key" { t.Fatal("write-only fields were lost") } return store.RuntimeDeploymentView{Provider: in.Provider}, nil @@ -35,7 +37,7 @@ func TestSandboxDeploymentChangesAuthenticateAndDecode(t *testing.T) { if err != nil { t.Fatal(err) } - const selection = `{"provider":"e2b","expected_generation":2,"e2b":{"api_key":"synthetic-private-key","template":"qualified:build"}}` + const selection = `{"provider":"e2b","expected_generation":2,"credential":{"api_key":"synthetic-private-key"},"configuration":{"template":"qualified:build"}}` for _, tc := range []struct { method, path, token, body string status int diff --git a/services/agents-api/internal/api/sandbox_deployment_setup.go b/services/agents-api/internal/api/sandbox_deployment_setup.go index 5c06a104b..26b4de156 100644 --- a/services/agents-api/internal/api/sandbox_deployment_setup.go +++ b/services/agents-api/internal/api/sandbox_deployment_setup.go @@ -7,43 +7,33 @@ import ( "net/url" "strconv" + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/sandbox/providers" + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/sandbox" "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" ) -// SandboxE2BInput is write-only provider configuration. Safe responses use the -// store's separate deployment view and never serialize this request. -type SandboxE2BInput struct { - APIKey *string `json:"api_key,omitempty"` - APIURL string `json:"api_url,omitempty"` - Domain string `json:"domain,omitempty"` - Template string `json:"template"` -} - type SandboxDeploymentInput struct { ExpectedGeneration *uint64 `json:"expected_generation" binding:"required"` // Per-sandbox limits, required for Docker and microsandbox. E2B may omit // them; Core then uses the validated template build's cpus and memory_mib. - Resources sandbox.Resources `json:"resources"` - Runtime *sandbox.RuntimeRelease `json:"runtime,omitempty"` - Provider string `json:"provider"` - E2B *SandboxE2BInput `json:"e2b,omitempty"` + Resources sandbox.Resources `json:"resources"` + Runtime *sandbox.RuntimeRelease `json:"runtime,omitempty"` + Provider string `json:"provider"` + Configuration json.RawMessage `json:"configuration" swaggertype:"object"` + Credential json.RawMessage `json:"credential,omitempty" swaggertype:"object"` } type SandboxDeploymentChangeInput struct { SandboxDeploymentInput } -func (v SandboxDeploymentInput) request() store.SandboxDeploymentSetupRequest { - input := store.SandboxDeploymentSetupRequest{ExpectedGeneration: *v.ExpectedGeneration, Provider: v.Provider, DeploymentSpec: sandbox.DeploymentSpec{Resources: v.Resources, Runtime: v.Runtime}} - if v.E2B != nil { - input.E2B = &sandbox.E2BConfiguration{Template: v.E2B.Template, APIURL: v.E2B.APIURL, Domain: v.E2B.Domain} - if v.E2B.APIKey != nil { - input.E2B.APIKey = *v.E2B.APIKey - input.E2B.ReplaceCredential = true - } - } - return input +func (v SandboxDeploymentInput) request() (store.SandboxDeploymentSetupRequest, error) { + c, err := providers.DecodeInput(v.Provider, v.Configuration, v.Credential) + if err != nil { + return store.SandboxDeploymentSetupRequest{}, err + } + return store.SandboxDeploymentSetupRequest{ExpectedGeneration: *v.ExpectedGeneration, Provider: v.Provider, DeploymentSpec: sandbox.DeploymentSpec{Resources: v.Resources, Runtime: v.Runtime}, Configuration: c}, nil } // rejectCoreURL names the retired member instead of reporting a generic unknown @@ -91,7 +81,7 @@ func (h *Handler) initializeSandboxDeployment(w http.ResponseWriter, r *http.Req return } var input SandboxDeploymentInput - if decodeInputObject(raw, &input, "provider", "e2b", "resources", "runtime", "expected_generation") != nil || input.ExpectedGeneration == nil || nullSandboxKey(raw) { + if decodeInputObject(raw, &input, "provider", "configuration", "credential", "resources", "runtime", "expected_generation") != nil || input.ExpectedGeneration == nil { writeStoreError(w, r, store.ErrInvalidInput) return } @@ -99,7 +89,12 @@ func (h *Handler) initializeSandboxDeployment(w http.ResponseWriter, r *http.Req writeStoreError(w, r, store.ErrSandboxDeploymentConflict) return } - result, err := h.sandboxSetup(r.Context(), input.request()) + selection, err := input.request() + if err != nil { + writeStoreError(w, r, err) + return + } + result, err := h.sandboxSetup(r.Context(), selection) if err != nil { writeStoreError(w, r, err) return @@ -126,7 +121,7 @@ func (h *Handler) updateSandboxDeployment(w http.ResponseWriter, r *http.Request return } var input SandboxDeploymentChangeInput - if decodeInputObject(raw, &input, "provider", "e2b", "resources", "runtime", "expected_generation") != nil || input.ExpectedGeneration == nil || nullSandboxKey(raw) { + if decodeInputObject(raw, &input, "provider", "configuration", "credential", "resources", "runtime", "expected_generation") != nil || input.ExpectedGeneration == nil { writeStoreError(w, r, store.ErrInvalidInput) return } @@ -134,7 +129,12 @@ func (h *Handler) updateSandboxDeployment(w http.ResponseWriter, r *http.Request writeStoreError(w, r, store.ErrSandboxDeploymentConflict) return } - result, err := h.sandboxUpdate(r.Context(), store.SandboxDeploymentUpdateRequest{SandboxDeploymentSetupRequest: input.request(), ExpectedGeneration: *input.ExpectedGeneration}) + selection, err := input.request() + if err != nil { + writeStoreError(w, r, err) + return + } + result, err := h.sandboxUpdate(r.Context(), store.SandboxDeploymentUpdateRequest{SandboxDeploymentSetupRequest: selection, ExpectedGeneration: *input.ExpectedGeneration}) if err != nil { writeStoreError(w, r, err) return @@ -162,7 +162,7 @@ func (h *Handler) startSandboxReset(w http.ResponseWriter, r *http.Request) { Clear string `json:"clear"` DeadlineSeconds *int32 `json:"deadline_seconds"` } - if decodeInputObject(raw, &input, "expected_generation", "clear", "deadline_seconds") != nil || input.ExpectedGeneration == nil || nullSandboxKey(raw) { + if decodeInputObject(raw, &input, "expected_generation", "clear", "deadline_seconds") != nil || input.ExpectedGeneration == nil { writeError(w, http.StatusBadRequest, "invalid_request_error", "A current expected_generation is required.", "expected_generation") return } @@ -227,15 +227,3 @@ func parseResetGeneration(r *http.Request) (uint64, error) { } return strconv.ParseUint(query.Get("expected_generation"), 10, 64) } - -// Null is an invalid explicit credential, not the omitted-key preservation path. -func nullSandboxKey(raw json.RawMessage) bool { - var body struct { - E2B map[string]json.RawMessage `json:"e2b"` - } - if json.Unmarshal(raw, &body) != nil { - return false - } - key, present := body.E2B["api_key"] - return present && string(key) == "null" -} diff --git a/services/agents-api/internal/api/sandbox_e2b_discovery.go b/services/agents-api/internal/api/sandbox_e2b_discovery.go deleted file mode 100644 index 577852b67..000000000 --- a/services/agents-api/internal/api/sandbox_e2b_discovery.go +++ /dev/null @@ -1,85 +0,0 @@ -package api - -import ( - "context" - "errors" - "net/http" - - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/sandbox" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/sandbox/e2b" - "github.com/go-chi/chi/v5" -) - -// Discovery credentials are transient and never included in the response. -type SandboxE2BDiscoveryInput struct { - APIKey string `json:"api_key"` - APIURL string `json:"api_url,omitempty"` - Domain string `json:"domain,omitempty"` -} -type SandboxE2BDiscoveryResult struct { - Templates []e2b.TemplateSummary `json:"templates"` - Builds []e2b.ReadyBuild `json:"builds"` -} - -func WithSandboxE2BDiscovery(discover func(context.Context, SandboxE2BDiscoveryInput, string) (SandboxE2BDiscoveryResult, error)) Option { - return func(h *Handler) { h.sandboxE2BDiscover = discover } -} - -// @Summary List templates visible to an E2B credential -// @Description Core key only. Uses a transient E2B credential and endpoint through the pinned SDK helper; returns safe template metadata. Does not save the credential or allocate compute. -// @Tags Sandbox Manager -// @Accept json -// @Produce json -// @Security DeploymentAdminAuth -// @Param body body api.SandboxE2BDiscoveryInput true "Transient E2B connection" -// @Success 200 {object} api.SandboxE2BDiscoveryResult -// @Failure 400,401,503 {object} CoreErrorResponse -// @Router /core/v1/sandbox/e2b/templates [post] -func (h *Handler) discoverSandboxE2BTemplates(w http.ResponseWriter, r *http.Request) { - h.discoverSandboxE2B(w, r, "") -} - -// @Summary List ready builds for an E2B template -// @Description Core key only. Reads one template through the pinned SDK helper with a transient E2B credential. Returns ready builds only, without allocating compute. -// @Tags Sandbox Manager -// @Accept json -// @Produce json -// @Security DeploymentAdminAuth -// @Param template_id path string true "Template ID" -// @Param body body api.SandboxE2BDiscoveryInput true "Transient E2B connection" -// @Success 200 {object} api.SandboxE2BDiscoveryResult -// @Failure 400,401,503 {object} CoreErrorResponse -// @Router /core/v1/sandbox/e2b/templates/{template_id}/builds [post] -func (h *Handler) discoverSandboxE2BBuilds(w http.ResponseWriter, r *http.Request) { - h.discoverSandboxE2B(w, r, chi.URLParam(r, "template_id")) -} - -func (h *Handler) discoverSandboxE2B(w http.ResponseWriter, r *http.Request, template string) { - raw, ok := readJSONBody(w, r) - if !ok { - return - } - var input SandboxE2BDiscoveryInput - if decodeInputObject(raw, &input, "api_key", "api_url", "domain") != nil || h.sandboxE2BDiscover == nil { - writeError(w, http.StatusBadRequest, "invalid_request_error", "Invalid E2B discovery request.", "") - return - } - result, err := h.sandboxE2BDiscover(r.Context(), input, template) - if err != nil { - if errors.Is(err, sandbox.ErrInvalid) { - writeError(w, http.StatusBadRequest, "invalid_request_error", "The E2B credential, endpoint, or template was rejected.", "") - } else { - writeError(w, http.StatusServiceUnavailable, "provider_unavailable", "E2B template discovery is unavailable. Check the credential, endpoint and provider connection, then retry.", "") - } - return - } - if template == "" { - writeJSON(w, http.StatusOK, struct { - Templates []e2b.TemplateSummary `json:"templates"` - }{Templates: result.Templates}) - } else { - writeJSON(w, http.StatusOK, struct { - Builds []e2b.ReadyBuild `json:"builds"` - }{Builds: result.Builds}) - } -} diff --git a/services/agents-api/internal/api/sandbox_e2b_discovery_test.go b/services/agents-api/internal/api/sandbox_e2b_discovery_test.go deleted file mode 100644 index 6c2d1b2a7..000000000 --- a/services/agents-api/internal/api/sandbox_e2b_discovery_test.go +++ /dev/null @@ -1,58 +0,0 @@ -package api - -import ( - "context" - "errors" - "net/http" - "net/http/httptest" - "strings" - "testing" - - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/device" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/sandbox/e2b" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" -) - -func TestSandboxE2BDiscoveryAuthenticationAndCredentialPrivacy(t *testing.T) { - project, _ := NewAuthenticator([]APIKey{callerBinding()}) - admin, _ := NewDeploymentAuthenticator([]string{device.HashCredential("administrator")}) - calls := 0 - discover := func(_ context.Context, input SandboxE2BDiscoveryInput, template string) (SandboxE2BDiscoveryResult, error) { - calls++ - if input.APIKey != "private-test-key" { - t.Fatal("wrong credential") - } - if template == "bad" { - return SandboxE2BDiscoveryResult{}, errors.New("private-test-key in provider error") - } - if template != "" { - return SandboxE2BDiscoveryResult{Builds: []e2b.ReadyBuild{}}, nil - } - return SandboxE2BDiscoveryResult{Templates: []e2b.TemplateSummary{}}, nil - } - h, err := NewHandler(&recordingStore{}, project, "codex", WithSandboxManager(&store.Store{}, admin), WithSandboxE2BDiscovery(discover)) - if err != nil { - t.Fatal(err) - } - for _, tc := range []struct { - token, path, body string - status int - }{ - {"caller", "/core/v1/sandbox/e2b/templates", `{"api_key":"private-test-key"}`, 401}, - {"administrator", "/core/v1/sandbox/e2b/templates", `{"api_key":"private-test-key","unexpected":true}`, 400}, - {"administrator", "/core/v1/sandbox/e2b/templates", `{"api_key":"private-test-key"}`, 200}, - {"administrator", "/core/v1/sandbox/e2b/templates/tpl_123/builds", `{"api_key":"private-test-key"}`, 200}, - {"administrator", "/core/v1/sandbox/e2b/templates/bad/builds", `{"api_key":"private-test-key"}`, 503}, - } { - req := httptest.NewRequest(http.MethodPost, tc.path, strings.NewReader(tc.body)) - req.Header.Set("Authorization", "Bearer "+tc.token) - result := httptest.NewRecorder() - h.ServeHTTP(result, req) - if result.Code != tc.status || strings.Contains(result.Body.String(), "private-test-key") { - t.Fatal(result.Code, result.Body.String()) - } - } - if calls != 3 { - t.Fatal(calls) - } -} diff --git a/services/agents-api/internal/api/sandbox_manager.go b/services/agents-api/internal/api/sandbox_manager.go index d88883ce7..bd7f6ccbf 100644 --- a/services/agents-api/internal/api/sandbox_manager.go +++ b/services/agents-api/internal/api/sandbox_manager.go @@ -53,8 +53,7 @@ func (h *Handler) registerSandboxManagerRoutes(r chi.Router) { return } r.Get("/sandbox/deployment", h.sandboxDeployment) - r.Post("/sandbox/e2b/templates", h.discoverSandboxE2BTemplates) - r.Post("/sandbox/e2b/templates/{template_id}/builds", h.discoverSandboxE2BBuilds) + r.Post("/sandbox/providers/{provider}/discovery", h.discoverSandboxConfiguration) r.Post("/sandbox/deployment", h.initializeSandboxDeployment) r.Put("/sandbox/deployment", h.updateSandboxDeployment) r.Post("/sandbox/deployment/reset", h.startSandboxReset) diff --git a/services/agents-api/internal/api/sandbox_node_configuration.go b/services/agents-api/internal/api/sandbox_node_configuration.go index cdd6370e1..a8f308e5f 100644 --- a/services/agents-api/internal/api/sandbox_node_configuration.go +++ b/services/agents-api/internal/api/sandbox_node_configuration.go @@ -1,10 +1,11 @@ package api import ( - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" "math" "net/http" "strconv" + + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" ) // @Summary Read the active configuration for node installation diff --git a/services/agents-api/internal/api/sandbox_node_detail_test.go b/services/agents-api/internal/api/sandbox_node_detail_test.go index bcad20c2b..dcea0b0e8 100644 --- a/services/agents-api/internal/api/sandbox_node_detail_test.go +++ b/services/agents-api/internal/api/sandbox_node_detail_test.go @@ -1,9 +1,10 @@ package api import ( + "testing" + "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/device" "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" - "testing" ) func TestSandboxNodeDetailValidationAndAuthentication(t *testing.T) { diff --git a/services/agents-api/internal/db/queries/sandbox_deployment_setup.sql b/services/agents-api/internal/db/queries/sandbox_deployment_setup.sql index e26c78ca0..142f0720f 100644 --- a/services/agents-api/internal/db/queries/sandbox_deployment_setup.sql +++ b/services/agents-api/internal/db/queries/sandbox_deployment_setup.sql @@ -4,16 +4,13 @@ owner_epoch=owner_epoch+1, updated_at=clock_timestamp() WHERE singleton=true; -- name: InitializeSandboxDeployment :exec UPDATE runtime_deployment SET provider_kind=$1, backend_fingerprint=$2, -idle_seconds=$3, retention_seconds=$4, generation=$5, mode=$6, e2b_template=$7, e2b_credential=$8, specification=$9, -e2b_api_url=sqlc.arg(e2b_api_url), e2b_domain=sqlc.arg(e2b_domain), -e2b_template_build_status=sqlc.narg(e2b_template_build_status), e2b_template_cpus=sqlc.narg(e2b_template_cpus), -e2b_template_memory_mib=sqlc.narg(e2b_template_memory_mib), e2b_template_root_disk_mib=sqlc.narg(e2b_template_root_disk_mib), +idle_seconds=$3, retention_seconds=$4, generation=$5, mode=$6, +provider_config=sqlc.arg(provider_config),provider_metadata=sqlc.arg(provider_metadata),provider_credential=sqlc.arg(provider_credential),specification=sqlc.arg(specification), updated_at=clock_timestamp() WHERE singleton=true; --- name: RecordSandboxTemplateBuild :exec -UPDATE runtime_deployment SET e2b_template_build_status=sqlc.narg(e2b_template_build_status), e2b_template_cpus=sqlc.narg(e2b_template_cpus), -e2b_template_memory_mib=sqlc.narg(e2b_template_memory_mib), e2b_template_root_disk_mib=sqlc.narg(e2b_template_root_disk_mib), -updated_at=clock_timestamp() WHERE singleton=true AND e2b_template<>''; +-- name: RecordSandboxConfigurationMetadata :exec +UPDATE runtime_deployment SET provider_metadata=$1,updated_at=clock_timestamp() +WHERE singleton=true AND provider_kind<>''; -- name: RetireSandboxNodes :exec UPDATE runtime_nodes SET removed_at=clock_timestamp(),connection_id=NULL,provider_ready=false,ready_generation=NULL WHERE removed_at IS NULL; diff --git a/services/agents-api/internal/db/queries/sandbox_generations.sql b/services/agents-api/internal/db/queries/sandbox_generations.sql index e0902423b..12d86058e 100644 --- a/services/agents-api/internal/db/queries/sandbox_generations.sql +++ b/services/agents-api/internal/db/queries/sandbox_generations.sql @@ -1,6 +1,6 @@ -- name: RetainSandboxGeneration :exec -INSERT INTO runtime_deployment_generations(generation,provider_kind,specification,e2b_template,e2b_template_build_status,e2b_template_cpus,e2b_template_memory_mib,e2b_template_root_disk_mib,e2b_api_url,e2b_domain) -SELECT generation,provider_kind,specification,e2b_template,e2b_template_build_status,e2b_template_cpus,e2b_template_memory_mib,e2b_template_root_disk_mib,e2b_api_url,e2b_domain +INSERT INTO runtime_deployment_generations(generation,provider_kind,specification,provider_config,provider_metadata) +SELECT generation,provider_kind,specification,provider_config,provider_metadata FROM runtime_deployment d WHERE provider_kind <> '' AND ( EXISTS(SELECT 1 FROM runtime_allocations a WHERE a.state <> 'released' AND a.deployment_generation=d.generation) OR EXISTS(SELECT 1 FROM runtime_placements p WHERE p.released_at IS NULL AND p.deployment_generation=d.generation) diff --git a/services/agents-api/internal/db/queries/sandbox_reset.sql b/services/agents-api/internal/db/queries/sandbox_reset.sql index 2ca1e9af6..58e819620 100644 --- a/services/agents-api/internal/db/queries/sandbox_reset.sql +++ b/services/agents-api/internal/db/queries/sandbox_reset.sql @@ -20,8 +20,7 @@ WHERE singleton = true; -- name: CompleteSandboxReset :exec UPDATE runtime_deployment SET provider_kind = '', backend_fingerprint = '', mode = '', specification = '{}', idle_seconds = 0, retention_seconds = 0, - e2b_template = '', e2b_credential = NULL, e2b_api_url = '', e2b_domain = '', e2b_template_build_status = NULL, - e2b_template_cpus = NULL, e2b_template_memory_mib = NULL, e2b_template_root_disk_mib = NULL, + provider_config = '{}'::jsonb, provider_metadata = '{}'::jsonb, provider_credential = NULL, generation = generation + 1, owner_epoch = owner_epoch + 1, admission_paused = false, reset_clear = NULL, reset_requested_at = NULL, reset_deadline_at = NULL, reset_forced_at = NULL, reset_audit = NULL, diff --git a/services/agents-api/internal/db/sqlc/models.go b/services/agents-api/internal/db/sqlc/models.go index 736d05c02..eca6abc6f 100644 --- a/services/agents-api/internal/db/sqlc/models.go +++ b/services/agents-api/internal/db/sqlc/models.go @@ -253,47 +253,37 @@ type RuntimeAllocation struct { } type RuntimeDeployment struct { - Singleton bool `json:"singleton"` - InstallationID pgtype.UUID `json:"installation_id"` - BackendFingerprint string `json:"backend_fingerprint"` - AdmissionPaused bool `json:"admission_paused"` - UpdatedAt pgtype.Timestamptz `json:"updated_at"` - ProviderKind string `json:"provider_kind"` - LocalNodeID pgtype.UUID `json:"local_node_id"` - OwnerEpoch int64 `json:"owner_epoch"` - WebManaged bool `json:"web_managed"` - IdleSeconds int64 `json:"idle_seconds"` - RetentionSeconds int64 `json:"retention_seconds"` - Generation int64 `json:"generation"` - Mode string `json:"mode"` - E2bTemplate string `json:"e2b_template"` - E2bCredential []byte `json:"e2b_credential"` - Specification []byte `json:"specification"` - E2bTemplateBuildStatus pgtype.Text `json:"e2b_template_build_status"` - E2bTemplateCpus pgtype.Int4 `json:"e2b_template_cpus"` - E2bTemplateMemoryMib pgtype.Int4 `json:"e2b_template_memory_mib"` - E2bTemplateRootDiskMib pgtype.Int4 `json:"e2b_template_root_disk_mib"` - ResetClear pgtype.Text `json:"reset_clear"` - ResetRequestedAt pgtype.Timestamptz `json:"reset_requested_at"` - ResetDeadlineAt pgtype.Timestamptz `json:"reset_deadline_at"` - ResetForcedAt pgtype.Timestamptz `json:"reset_forced_at"` - ResetAudit []byte `json:"reset_audit"` - E2bApiUrl string `json:"e2b_api_url"` - E2bDomain string `json:"e2b_domain"` + Singleton bool `json:"singleton"` + InstallationID pgtype.UUID `json:"installation_id"` + BackendFingerprint string `json:"backend_fingerprint"` + AdmissionPaused bool `json:"admission_paused"` + UpdatedAt pgtype.Timestamptz `json:"updated_at"` + ProviderKind string `json:"provider_kind"` + LocalNodeID pgtype.UUID `json:"local_node_id"` + OwnerEpoch int64 `json:"owner_epoch"` + WebManaged bool `json:"web_managed"` + IdleSeconds int64 `json:"idle_seconds"` + RetentionSeconds int64 `json:"retention_seconds"` + Generation int64 `json:"generation"` + Mode string `json:"mode"` + ProviderCredential []byte `json:"provider_credential"` + Specification []byte `json:"specification"` + ResetClear pgtype.Text `json:"reset_clear"` + ResetRequestedAt pgtype.Timestamptz `json:"reset_requested_at"` + ResetDeadlineAt pgtype.Timestamptz `json:"reset_deadline_at"` + ResetForcedAt pgtype.Timestamptz `json:"reset_forced_at"` + ResetAudit []byte `json:"reset_audit"` + ProviderConfig []byte `json:"provider_config"` + ProviderMetadata []byte `json:"provider_metadata"` } type RuntimeDeploymentGeneration struct { - Generation int64 `json:"generation"` - ProviderKind string `json:"provider_kind"` - Specification []byte `json:"specification"` - E2bTemplate string `json:"e2b_template"` - E2bTemplateBuildStatus pgtype.Text `json:"e2b_template_build_status"` - E2bTemplateCpus pgtype.Int4 `json:"e2b_template_cpus"` - E2bTemplateMemoryMib pgtype.Int4 `json:"e2b_template_memory_mib"` - E2bTemplateRootDiskMib pgtype.Int4 `json:"e2b_template_root_disk_mib"` - CreatedAt pgtype.Timestamptz `json:"created_at"` - E2bApiUrl string `json:"e2b_api_url"` - E2bDomain string `json:"e2b_domain"` + Generation int64 `json:"generation"` + ProviderKind string `json:"provider_kind"` + Specification []byte `json:"specification"` + CreatedAt pgtype.Timestamptz `json:"created_at"` + ProviderConfig []byte `json:"provider_config"` + ProviderMetadata []byte `json:"provider_metadata"` } type RuntimeDeviceAuthority struct { diff --git a/services/agents-api/internal/db/sqlc/runtime_deployment.sql.go b/services/agents-api/internal/db/sqlc/runtime_deployment.sql.go index faf8de52c..e8f076e8b 100644 --- a/services/agents-api/internal/db/sqlc/runtime_deployment.sql.go +++ b/services/agents-api/internal/db/sqlc/runtime_deployment.sql.go @@ -55,7 +55,7 @@ func (q *Queries) CountRuntimeDeploymentResources(ctx context.Context) (CountRun } const lockRuntimeDeployment = `-- name: LockRuntimeDeployment :one -SELECT singleton, installation_id, backend_fingerprint, admission_paused, updated_at, provider_kind, local_node_id, owner_epoch, web_managed, idle_seconds, retention_seconds, generation, mode, e2b_template, e2b_credential, specification, e2b_template_build_status, e2b_template_cpus, e2b_template_memory_mib, e2b_template_root_disk_mib, reset_clear, reset_requested_at, reset_deadline_at, reset_forced_at, reset_audit, e2b_api_url, e2b_domain FROM runtime_deployment WHERE singleton = true FOR UPDATE +SELECT singleton, installation_id, backend_fingerprint, admission_paused, updated_at, provider_kind, local_node_id, owner_epoch, web_managed, idle_seconds, retention_seconds, generation, mode, provider_credential, specification, reset_clear, reset_requested_at, reset_deadline_at, reset_forced_at, reset_audit, provider_config, provider_metadata FROM runtime_deployment WHERE singleton = true FOR UPDATE ` func (q *Queries) LockRuntimeDeployment(ctx context.Context) (RuntimeDeployment, error) { @@ -75,20 +75,15 @@ func (q *Queries) LockRuntimeDeployment(ctx context.Context) (RuntimeDeployment, &i.RetentionSeconds, &i.Generation, &i.Mode, - &i.E2bTemplate, - &i.E2bCredential, + &i.ProviderCredential, &i.Specification, - &i.E2bTemplateBuildStatus, - &i.E2bTemplateCpus, - &i.E2bTemplateMemoryMib, - &i.E2bTemplateRootDiskMib, &i.ResetClear, &i.ResetRequestedAt, &i.ResetDeadlineAt, &i.ResetForcedAt, &i.ResetAudit, - &i.E2bApiUrl, - &i.E2bDomain, + &i.ProviderConfig, + &i.ProviderMetadata, ) return i, err } diff --git a/services/agents-api/internal/db/sqlc/runtime_nodes.sql.go b/services/agents-api/internal/db/sqlc/runtime_nodes.sql.go index c2507e7f5..dc5d98088 100644 --- a/services/agents-api/internal/db/sqlc/runtime_nodes.sql.go +++ b/services/agents-api/internal/db/sqlc/runtime_nodes.sql.go @@ -119,7 +119,7 @@ func (q *Queries) DisconnectRuntimeNode(ctx context.Context, arg DisconnectRunti } const getRuntimeDeployment = `-- name: GetRuntimeDeployment :one -SELECT singleton, installation_id, backend_fingerprint, admission_paused, updated_at, provider_kind, local_node_id, owner_epoch, web_managed, idle_seconds, retention_seconds, generation, mode, e2b_template, e2b_credential, specification, e2b_template_build_status, e2b_template_cpus, e2b_template_memory_mib, e2b_template_root_disk_mib, reset_clear, reset_requested_at, reset_deadline_at, reset_forced_at, reset_audit, e2b_api_url, e2b_domain FROM runtime_deployment WHERE singleton=true +SELECT singleton, installation_id, backend_fingerprint, admission_paused, updated_at, provider_kind, local_node_id, owner_epoch, web_managed, idle_seconds, retention_seconds, generation, mode, provider_credential, specification, reset_clear, reset_requested_at, reset_deadline_at, reset_forced_at, reset_audit, provider_config, provider_metadata FROM runtime_deployment WHERE singleton=true ` func (q *Queries) GetRuntimeDeployment(ctx context.Context) (RuntimeDeployment, error) { @@ -139,20 +139,15 @@ func (q *Queries) GetRuntimeDeployment(ctx context.Context) (RuntimeDeployment, &i.RetentionSeconds, &i.Generation, &i.Mode, - &i.E2bTemplate, - &i.E2bCredential, + &i.ProviderCredential, &i.Specification, - &i.E2bTemplateBuildStatus, - &i.E2bTemplateCpus, - &i.E2bTemplateMemoryMib, - &i.E2bTemplateRootDiskMib, &i.ResetClear, &i.ResetRequestedAt, &i.ResetDeadlineAt, &i.ResetForcedAt, &i.ResetAudit, - &i.E2bApiUrl, - &i.E2bDomain, + &i.ProviderConfig, + &i.ProviderMetadata, ) return i, err } diff --git a/services/agents-api/internal/db/sqlc/sandbox_deployment_setup.sql.go b/services/agents-api/internal/db/sqlc/sandbox_deployment_setup.sql.go index 088bd1311..48cb02cc5 100644 --- a/services/agents-api/internal/db/sqlc/sandbox_deployment_setup.sql.go +++ b/services/agents-api/internal/db/sqlc/sandbox_deployment_setup.sql.go @@ -32,29 +32,22 @@ func (q *Queries) ClaimWebSandboxDeployment(ctx context.Context, installationID const initializeSandboxDeployment = `-- name: InitializeSandboxDeployment :exec UPDATE runtime_deployment SET provider_kind=$1, backend_fingerprint=$2, -idle_seconds=$3, retention_seconds=$4, generation=$5, mode=$6, e2b_template=$7, e2b_credential=$8, specification=$9, -e2b_api_url=$10, e2b_domain=$11, -e2b_template_build_status=$12, e2b_template_cpus=$13, -e2b_template_memory_mib=$14, e2b_template_root_disk_mib=$15, +idle_seconds=$3, retention_seconds=$4, generation=$5, mode=$6, +provider_config=$7,provider_metadata=$8,provider_credential=$9,specification=$10, updated_at=clock_timestamp() WHERE singleton=true ` type InitializeSandboxDeploymentParams struct { - ProviderKind string `json:"provider_kind"` - BackendFingerprint string `json:"backend_fingerprint"` - IdleSeconds int64 `json:"idle_seconds"` - RetentionSeconds int64 `json:"retention_seconds"` - Generation int64 `json:"generation"` - Mode string `json:"mode"` - E2bTemplate string `json:"e2b_template"` - E2bCredential []byte `json:"e2b_credential"` - Specification []byte `json:"specification"` - E2bApiUrl string `json:"e2b_api_url"` - E2bDomain string `json:"e2b_domain"` - E2bTemplateBuildStatus pgtype.Text `json:"e2b_template_build_status"` - E2bTemplateCpus pgtype.Int4 `json:"e2b_template_cpus"` - E2bTemplateMemoryMib pgtype.Int4 `json:"e2b_template_memory_mib"` - E2bTemplateRootDiskMib pgtype.Int4 `json:"e2b_template_root_disk_mib"` + ProviderKind string `json:"provider_kind"` + BackendFingerprint string `json:"backend_fingerprint"` + IdleSeconds int64 `json:"idle_seconds"` + RetentionSeconds int64 `json:"retention_seconds"` + Generation int64 `json:"generation"` + Mode string `json:"mode"` + ProviderConfig []byte `json:"provider_config"` + ProviderMetadata []byte `json:"provider_metadata"` + ProviderCredential []byte `json:"provider_credential"` + Specification []byte `json:"specification"` } func (q *Queries) InitializeSandboxDeployment(ctx context.Context, arg InitializeSandboxDeploymentParams) error { @@ -65,39 +58,21 @@ func (q *Queries) InitializeSandboxDeployment(ctx context.Context, arg Initializ arg.RetentionSeconds, arg.Generation, arg.Mode, - arg.E2bTemplate, - arg.E2bCredential, + arg.ProviderConfig, + arg.ProviderMetadata, + arg.ProviderCredential, arg.Specification, - arg.E2bApiUrl, - arg.E2bDomain, - arg.E2bTemplateBuildStatus, - arg.E2bTemplateCpus, - arg.E2bTemplateMemoryMib, - arg.E2bTemplateRootDiskMib, ) return err } -const recordSandboxTemplateBuild = `-- name: RecordSandboxTemplateBuild :exec -UPDATE runtime_deployment SET e2b_template_build_status=$1, e2b_template_cpus=$2, -e2b_template_memory_mib=$3, e2b_template_root_disk_mib=$4, -updated_at=clock_timestamp() WHERE singleton=true AND e2b_template<>'' +const recordSandboxConfigurationMetadata = `-- name: RecordSandboxConfigurationMetadata :exec +UPDATE runtime_deployment SET provider_metadata=$1,updated_at=clock_timestamp() +WHERE singleton=true AND provider_kind<>'' ` -type RecordSandboxTemplateBuildParams struct { - E2bTemplateBuildStatus pgtype.Text `json:"e2b_template_build_status"` - E2bTemplateCpus pgtype.Int4 `json:"e2b_template_cpus"` - E2bTemplateMemoryMib pgtype.Int4 `json:"e2b_template_memory_mib"` - E2bTemplateRootDiskMib pgtype.Int4 `json:"e2b_template_root_disk_mib"` -} - -func (q *Queries) RecordSandboxTemplateBuild(ctx context.Context, arg RecordSandboxTemplateBuildParams) error { - _, err := q.db.Exec(ctx, recordSandboxTemplateBuild, - arg.E2bTemplateBuildStatus, - arg.E2bTemplateCpus, - arg.E2bTemplateMemoryMib, - arg.E2bTemplateRootDiskMib, - ) +func (q *Queries) RecordSandboxConfigurationMetadata(ctx context.Context, providerMetadata []byte) error { + _, err := q.db.Exec(ctx, recordSandboxConfigurationMetadata, providerMetadata) return err } diff --git a/services/agents-api/internal/db/sqlc/sandbox_generations.sql.go b/services/agents-api/internal/db/sqlc/sandbox_generations.sql.go index d4bb3ec85..65b79d9df 100644 --- a/services/agents-api/internal/db/sqlc/sandbox_generations.sql.go +++ b/services/agents-api/internal/db/sqlc/sandbox_generations.sql.go @@ -31,7 +31,7 @@ func (q *Queries) CollectSandboxGenerations(ctx context.Context) error { } const getSandboxGeneration = `-- name: GetSandboxGeneration :one -SELECT generation, provider_kind, specification, e2b_template, e2b_template_build_status, e2b_template_cpus, e2b_template_memory_mib, e2b_template_root_disk_mib, created_at, e2b_api_url, e2b_domain FROM runtime_deployment_generations WHERE generation=$1 +SELECT generation, provider_kind, specification, created_at, provider_config, provider_metadata FROM runtime_deployment_generations WHERE generation=$1 ` func (q *Queries) GetSandboxGeneration(ctx context.Context, generation int64) (RuntimeDeploymentGeneration, error) { @@ -41,20 +41,15 @@ func (q *Queries) GetSandboxGeneration(ctx context.Context, generation int64) (R &i.Generation, &i.ProviderKind, &i.Specification, - &i.E2bTemplate, - &i.E2bTemplateBuildStatus, - &i.E2bTemplateCpus, - &i.E2bTemplateMemoryMib, - &i.E2bTemplateRootDiskMib, &i.CreatedAt, - &i.E2bApiUrl, - &i.E2bDomain, + &i.ProviderConfig, + &i.ProviderMetadata, ) return i, err } const listSandboxGenerations = `-- name: ListSandboxGenerations :many -SELECT generation, provider_kind, specification, e2b_template, e2b_template_build_status, e2b_template_cpus, e2b_template_memory_mib, e2b_template_root_disk_mib, created_at, e2b_api_url, e2b_domain FROM runtime_deployment_generations WHERE generation > $1 ORDER BY generation LIMIT 32 +SELECT generation, provider_kind, specification, created_at, provider_config, provider_metadata FROM runtime_deployment_generations WHERE generation > $1 ORDER BY generation LIMIT 32 ` func (q *Queries) ListSandboxGenerations(ctx context.Context, generation int64) ([]RuntimeDeploymentGeneration, error) { @@ -70,14 +65,9 @@ func (q *Queries) ListSandboxGenerations(ctx context.Context, generation int64) &i.Generation, &i.ProviderKind, &i.Specification, - &i.E2bTemplate, - &i.E2bTemplateBuildStatus, - &i.E2bTemplateCpus, - &i.E2bTemplateMemoryMib, - &i.E2bTemplateRootDiskMib, &i.CreatedAt, - &i.E2bApiUrl, - &i.E2bDomain, + &i.ProviderConfig, + &i.ProviderMetadata, ); err != nil { return nil, err } @@ -90,8 +80,8 @@ func (q *Queries) ListSandboxGenerations(ctx context.Context, generation int64) } const retainSandboxGeneration = `-- name: RetainSandboxGeneration :exec -INSERT INTO runtime_deployment_generations(generation,provider_kind,specification,e2b_template,e2b_template_build_status,e2b_template_cpus,e2b_template_memory_mib,e2b_template_root_disk_mib,e2b_api_url,e2b_domain) -SELECT generation,provider_kind,specification,e2b_template,e2b_template_build_status,e2b_template_cpus,e2b_template_memory_mib,e2b_template_root_disk_mib,e2b_api_url,e2b_domain +INSERT INTO runtime_deployment_generations(generation,provider_kind,specification,provider_config,provider_metadata) +SELECT generation,provider_kind,specification,provider_config,provider_metadata FROM runtime_deployment d WHERE provider_kind <> '' AND ( EXISTS(SELECT 1 FROM runtime_allocations a WHERE a.state <> 'released' AND a.deployment_generation=d.generation) OR EXISTS(SELECT 1 FROM runtime_placements p WHERE p.released_at IS NULL AND p.deployment_generation=d.generation) diff --git a/services/agents-api/internal/db/sqlc/sandbox_reset.sql.go b/services/agents-api/internal/db/sqlc/sandbox_reset.sql.go index 2f63a4933..6ded68955 100644 --- a/services/agents-api/internal/db/sqlc/sandbox_reset.sql.go +++ b/services/agents-api/internal/db/sqlc/sandbox_reset.sql.go @@ -26,8 +26,7 @@ func (q *Queries) CancelSandboxReset(ctx context.Context) error { const completeSandboxReset = `-- name: CompleteSandboxReset :exec UPDATE runtime_deployment SET provider_kind = '', backend_fingerprint = '', mode = '', specification = '{}', idle_seconds = 0, retention_seconds = 0, - e2b_template = '', e2b_credential = NULL, e2b_api_url = '', e2b_domain = '', e2b_template_build_status = NULL, - e2b_template_cpus = NULL, e2b_template_memory_mib = NULL, e2b_template_root_disk_mib = NULL, + provider_config = '{}'::jsonb, provider_metadata = '{}'::jsonb, provider_credential = NULL, generation = generation + 1, owner_epoch = owner_epoch + 1, admission_paused = false, reset_clear = NULL, reset_requested_at = NULL, reset_deadline_at = NULL, reset_forced_at = NULL, reset_audit = NULL, @@ -51,7 +50,7 @@ func (q *Queries) ForceSandboxReset(ctx context.Context) error { } const getSandboxDeploymentSnapshot = `-- name: GetSandboxDeploymentSnapshot :one -WITH deployment AS MATERIALIZED (SELECT singleton, installation_id, backend_fingerprint, admission_paused, updated_at, provider_kind, local_node_id, owner_epoch, web_managed, idle_seconds, retention_seconds, generation, mode, e2b_template, e2b_credential, specification, e2b_template_build_status, e2b_template_cpus, e2b_template_memory_mib, e2b_template_root_disk_mib, reset_clear, reset_requested_at, reset_deadline_at, reset_forced_at, reset_audit, e2b_api_url, e2b_domain FROM runtime_deployment WHERE singleton = true LIMIT 1), +WITH deployment AS MATERIALIZED (SELECT singleton, installation_id, backend_fingerprint, admission_paused, updated_at, provider_kind, local_node_id, owner_epoch, web_managed, idle_seconds, retention_seconds, generation, mode, provider_credential, specification, reset_clear, reset_requested_at, reset_deadline_at, reset_forced_at, reset_audit, provider_config, provider_metadata FROM runtime_deployment WHERE singleton = true LIMIT 1), observed AS MATERIALIZED (SELECT clock_timestamp() AS as_of), held AS ( SELECT a.deployment_generation, a.node_id, s.id AS session_id, e.id AS environment_id, false AS pending, @@ -89,7 +88,7 @@ held AS ( ), offline AS ( SELECT node_id, name, count(*)::bigint AS resources FROM classified WHERE offline GROUP BY node_id, name ) -SELECT d.singleton, d.installation_id, d.backend_fingerprint, d.admission_paused, d.updated_at, d.provider_kind, d.local_node_id, d.owner_epoch, d.web_managed, d.idle_seconds, d.retention_seconds, d.generation, d.mode, d.e2b_template, d.e2b_credential, d.specification, d.e2b_template_build_status, d.e2b_template_cpus, d.e2b_template_memory_mib, d.e2b_template_root_disk_mib, d.reset_clear, d.reset_requested_at, d.reset_deadline_at, d.reset_forced_at, d.reset_audit, d.e2b_api_url, d.e2b_domain, +SELECT d.singleton, d.installation_id, d.backend_fingerprint, d.admission_paused, d.updated_at, d.provider_kind, d.local_node_id, d.owner_epoch, d.web_managed, d.idle_seconds, d.retention_seconds, d.generation, d.mode, d.provider_credential, d.specification, d.reset_clear, d.reset_requested_at, d.reset_deadline_at, d.reset_forced_at, d.reset_audit, d.provider_config, d.provider_metadata, (SELECT count(*) FROM classified WHERE NOT pending)::bigint AS allocations, (SELECT count(*) FROM classified WHERE pending)::bigint AS pending, jsonb_build_object( @@ -138,20 +137,15 @@ func (q *Queries) GetSandboxDeploymentSnapshot(ctx context.Context) (GetSandboxD &i.RuntimeDeployment.RetentionSeconds, &i.RuntimeDeployment.Generation, &i.RuntimeDeployment.Mode, - &i.RuntimeDeployment.E2bTemplate, - &i.RuntimeDeployment.E2bCredential, + &i.RuntimeDeployment.ProviderCredential, &i.RuntimeDeployment.Specification, - &i.RuntimeDeployment.E2bTemplateBuildStatus, - &i.RuntimeDeployment.E2bTemplateCpus, - &i.RuntimeDeployment.E2bTemplateMemoryMib, - &i.RuntimeDeployment.E2bTemplateRootDiskMib, &i.RuntimeDeployment.ResetClear, &i.RuntimeDeployment.ResetRequestedAt, &i.RuntimeDeployment.ResetDeadlineAt, &i.RuntimeDeployment.ResetForcedAt, &i.RuntimeDeployment.ResetAudit, - &i.RuntimeDeployment.E2bApiUrl, - &i.RuntimeDeployment.E2bDomain, + &i.RuntimeDeployment.ProviderConfig, + &i.RuntimeDeployment.ProviderMetadata, &i.Allocations, &i.Pending, &i.Remaining, diff --git a/services/agents-api/internal/engine/mcp_test.go b/services/agents-api/internal/engine/mcp_test.go index be70bad26..0fb9cb017 100644 --- a/services/agents-api/internal/engine/mcp_test.go +++ b/services/agents-api/internal/engine/mcp_test.go @@ -1,9 +1,10 @@ package engine import ( + "testing" + v1 "github.com/MiniMax-AI-Dev/parsar/contracts/agents-api/v1" "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" - "testing" ) func TestMCPOriginQualification(t *testing.T) { diff --git a/services/agents-api/internal/execution/archive_cancellation_cleanup_test.go b/services/agents-api/internal/execution/archive_cancellation_cleanup_test.go index 7b4ebfde1..78ab86ade 100644 --- a/services/agents-api/internal/execution/archive_cancellation_cleanup_test.go +++ b/services/agents-api/internal/execution/archive_cancellation_cleanup_test.go @@ -3,14 +3,16 @@ package execution import ( "context" "encoding/json" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/providercontract" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/sandbox/microsandbox" "net/http" "net/http/httptest" "net/url" "testing" "time" + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/providercontract" + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/sandbox/e2b" + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/sandbox/microsandbox" + v1 "github.com/MiniMax-AI-Dev/parsar/contracts/agents-api/v1" "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/device" "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/gateway" @@ -66,7 +68,7 @@ func TestArchiveWaitingCleanupReceiptBarrier(t *testing.T) { if err := writer.ClaimWebSandboxDeployment(t.Context(), installation); err != nil { t.Fatal(err) } - selection := store.SandboxDeploymentSetupRequest{Provider: "e2b", E2B: &sandbox.E2BConfiguration{APIKey: "fixture-key", Template: "runtime:" + uuid.NewString()}} + selection := store.SandboxDeploymentSetupRequest{Provider: "e2b", Configuration: &e2b.DeploymentConfiguration{APIKey: "fixture-key", Template: "runtime:" + uuid.NewString()}} selection.Resources.CPUs = 2 selection.Resources.MemoryMiB = 2048 if _, err := writer.InitializeSandboxDeployment(t.Context(), installation, selection); err != nil { diff --git a/services/agents-api/internal/execution/environment_capabilities_test.go b/services/agents-api/internal/execution/environment_capabilities_test.go index c9e48bf29..e43bd6ac8 100644 --- a/services/agents-api/internal/execution/environment_capabilities_test.go +++ b/services/agents-api/internal/execution/environment_capabilities_test.go @@ -2,10 +2,11 @@ package execution import ( "encoding/json" - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" "slices" "testing" + + "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" ) func TestSelfHostedCapabilitySourcesAreFrozenAndStrict(t *testing.T) { diff --git a/services/agents-api/internal/execution/environment_test.go b/services/agents-api/internal/execution/environment_test.go index 6a8ce5019..f78e32e1b 100644 --- a/services/agents-api/internal/execution/environment_test.go +++ b/services/agents-api/internal/execution/environment_test.go @@ -1,8 +1,9 @@ package execution import ( - v1 "github.com/MiniMax-AI-Dev/parsar/contracts/agents-api/v1" "testing" + + v1 "github.com/MiniMax-AI-Dev/parsar/contracts/agents-api/v1" ) func TestExecutionEnvironmentDoesNotDefaultToLocal(t *testing.T) { diff --git a/services/agents-api/internal/execution/runtime_capabilities_test.go b/services/agents-api/internal/execution/runtime_capabilities_test.go index c141327cc..063f6fb2b 100644 --- a/services/agents-api/internal/execution/runtime_capabilities_test.go +++ b/services/agents-api/internal/execution/runtime_capabilities_test.go @@ -4,12 +4,13 @@ import ( "bytes" "context" "errors" + "testing" + "github.com/MiniMax-AI-Dev/parsar/internal/agentcapabilities" "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" "github.com/MiniMax-AI-Dev/parsar/internal/agentplugin" "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" "github.com/google/uuid" - "testing" ) type capabilityFixture struct { diff --git a/services/agents-api/internal/execution/sandbox_deployment_setup_test.go b/services/agents-api/internal/execution/sandbox_deployment_setup_test.go index 39d0d7a52..4f878b2d2 100644 --- a/services/agents-api/internal/execution/sandbox_deployment_setup_test.go +++ b/services/agents-api/internal/execution/sandbox_deployment_setup_test.go @@ -9,6 +9,8 @@ import ( "testing" "time" + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/sandbox/e2b" + "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/gateway" "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/sandbox" "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/sandbox/node" @@ -140,7 +142,7 @@ func TestRejectedSandboxCandidatePreservesActiveGeneration(t *testing.T) { if err != nil { t.Fatal(err) } - input := store.SandboxDeploymentSetupRequest{Provider: "e2b", E2B: &sandbox.E2BConfiguration{APIKey: "fixture-key", Template: "runtime:" + uuid.NewString()}, DeploymentSpec: sandbox.DeploymentSpec{Resources: sandbox.Resources{CPUs: 2, MemoryMiB: 1024}}} + input := store.SandboxDeploymentSetupRequest{Provider: "e2b", Configuration: &e2b.DeploymentConfiguration{APIKey: "fixture-key", Template: "runtime:" + uuid.NewString()}, DeploymentSpec: sandbox.DeploymentSpec{Resources: sandbox.Resources{CPUs: 2, MemoryMiB: 1024}}} if _, err := m.prepareCandidate(t.Context(), input); !errors.Is(err, rejected) { t.Fatal("candidate rejection was lost", err) } @@ -170,7 +172,7 @@ func TestSandboxCandidateValidationDoesNotHoldManagerLock(t *testing.T) { done := make(chan struct{}) go func() { defer close(done) - _, _ = m.prepareCandidate(t.Context(), store.SandboxDeploymentSetupRequest{Provider: "e2b", E2B: &sandbox.E2BConfiguration{APIKey: "fixture-key", Template: "runtime:" + uuid.NewString()}, DeploymentSpec: sandbox.DeploymentSpec{Resources: sandbox.Resources{CPUs: 2, MemoryMiB: 1024}}}) + _, _ = m.prepareCandidate(t.Context(), store.SandboxDeploymentSetupRequest{Provider: "e2b", Configuration: &e2b.DeploymentConfiguration{APIKey: "fixture-key", Template: "runtime:" + uuid.NewString()}, DeploymentSpec: sandbox.DeploymentSpec{Resources: sandbox.Resources{CPUs: 2, MemoryMiB: 1024}}}) }() <-entered stopped := make(chan struct{}) diff --git a/services/agents-api/internal/execution/sandbox_generations_test.go b/services/agents-api/internal/execution/sandbox_generations_test.go index ecc457d1d..acff1c485 100644 --- a/services/agents-api/internal/execution/sandbox_generations_test.go +++ b/services/agents-api/internal/execution/sandbox_generations_test.go @@ -21,7 +21,7 @@ func TestE2BReplacementVerifiesTwiceAndNeverPublishesFailedCommit(t *testing.T) if err := writer.ClaimWebSandboxDeployment(t.Context(), id); err != nil { t.Fatal(err) } - input := store.SandboxDeploymentSetupRequest{Provider: "e2b", E2B: &sandbox.E2BConfiguration{APIKey: "old-key", Template: "runtime:" + uuid.NewString()}} + input := store.SandboxDeploymentSetupRequest{Provider: "e2b", Configuration: &e2b.DeploymentConfiguration{APIKey: "old-key", Template: "runtime:" + uuid.NewString()}} input.Resources.CPUs = 2 input.Resources.MemoryMiB = 2048 if _, err := writer.InitializeSandboxDeployment(t.Context(), id, input); err != nil { @@ -32,7 +32,7 @@ func TestE2BReplacementVerifiesTwiceAndNeverPublishesFailedCommit(t *testing.T) provider := hub.Proxy(uuid.NewString(), "docker", 1) verifyCalls, published, fenced, released := 0, 0, 0, 0 var rejectAt int - var rejection error = e2b.ErrTeamMismatch + var rejection error = sandbox.ErrCredentialOwnership config := NewDeferredRuntimeProvider(id, func(ctx context.Context) (*RuntimeProvider, error) { setup, err := s.GetSandboxSetup(ctx) if err != nil { @@ -63,7 +63,7 @@ func TestE2BReplacementVerifiesTwiceAndNeverPublishesFailedCommit(t *testing.T) t.Fatal(err) } worker := &Worker{runtimes: m} - input.E2B.APIKey = "candidate-key" + input.Configuration.(*e2b.DeploymentConfiguration).APIKey = "candidate-key" request := store.SandboxDeploymentUpdateRequest{SandboxDeploymentSetupRequest: input, ExpectedGeneration: 1} audit := adminaudit.WithSource(t.Context(), adminaudit.Source{CredentialID: "test", RequestID: "test", TraceID: "test"}) for _, failure := range []string{"preliminary", "final", "unanchored legacy or revoked committed key", "unknown ownership", "commit"} { @@ -80,7 +80,7 @@ func TestE2BReplacementVerifiesTwiceAndNeverPublishesFailedCommit(t *testing.T) rejection = &store.SandboxResetRequiredError{CurrentProvider: "e2b", RequestedProvider: "e2b"} case "unknown ownership": rejectAt = 1 - rejection = e2b.ErrRequestUnconfirmed + rejection = sandbox.ErrConfigurationUnconfirmed case "commit": ctx = t.Context() } @@ -88,7 +88,7 @@ func TestE2BReplacementVerifiesTwiceAndNeverPublishesFailedCommit(t *testing.T) t.Fatal("failure published", failure) } committed, err := s.GetSandboxSetup(t.Context()) - if err != nil || committed.Generation != 1 || committed.E2B.APIKey != "old-key" || published != 0 || fenced != released { + if err != nil || committed.Generation != 1 || committed.Configuration.(*e2b.DeploymentConfiguration).APIKey != "old-key" || published != 0 || fenced != released { t.Fatal("partial credential publication", failure, err) } current, err := m.node("") diff --git a/services/agents-api/internal/execution/sandbox_reset_test.go b/services/agents-api/internal/execution/sandbox_reset_test.go index 09249ba5b..032d10a10 100644 --- a/services/agents-api/internal/execution/sandbox_reset_test.go +++ b/services/agents-api/internal/execution/sandbox_reset_test.go @@ -8,10 +8,11 @@ import ( "testing" "time" + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/sandbox/e2b" + "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/gateway" "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/adminaudit" "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/credentialcrypto" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/sandbox" "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/sandbox/node" "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" "github.com/google/uuid" @@ -90,7 +91,7 @@ func TestSandboxResetPageTimeoutRecoversCommittedOwner(t *testing.T) { if err := w.ClaimWebSandboxDeployment(t.Context(), id); err != nil { t.Fatal(err) } - selection := store.SandboxDeploymentSetupRequest{Provider: "e2b", E2B: &sandbox.E2BConfiguration{APIKey: "fixture-key", Template: "runtime:" + uuid.NewString()}} + selection := store.SandboxDeploymentSetupRequest{Provider: "e2b", Configuration: &e2b.DeploymentConfiguration{APIKey: "fixture-key", Template: "runtime:" + uuid.NewString()}} selection.Resources.CPUs = 2 selection.Resources.MemoryMiB = 2048 if _, err := w.InitializeSandboxDeployment(t.Context(), id, selection); err != nil { diff --git a/services/agents-api/internal/execution/sandbox_snapshot_budget_test.go b/services/agents-api/internal/execution/sandbox_snapshot_budget_test.go index 8608e536a..6c0fd2cdb 100644 --- a/services/agents-api/internal/execution/sandbox_snapshot_budget_test.go +++ b/services/agents-api/internal/execution/sandbox_snapshot_budget_test.go @@ -8,9 +8,10 @@ import ( "testing" "time" + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/sandbox/e2b" + "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/gateway" "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/adminaudit" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/sandbox" "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/sandbox/node" "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" "github.com/google/uuid" @@ -68,7 +69,7 @@ func TestSandboxResetSnapshotFitsPageBudget(t *testing.T) { if err := w.ClaimWebSandboxDeployment(t.Context(), id); err != nil { t.Fatal(err) } - selection := store.SandboxDeploymentSetupRequest{Provider: "e2b", E2B: &sandbox.E2BConfiguration{APIKey: "fixture-key", Template: "runtime:" + uuid.NewString()}} + selection := store.SandboxDeploymentSetupRequest{Provider: "e2b", Configuration: &e2b.DeploymentConfiguration{APIKey: "fixture-key", Template: "runtime:" + uuid.NewString()}} selection.Resources.CPUs = 2 selection.Resources.MemoryMiB = 2048 if _, err := w.InitializeSandboxDeployment(t.Context(), id, selection); err != nil { diff --git a/services/agents-api/internal/runtimeenrollment/connection_test.go b/services/agents-api/internal/runtimeenrollment/connection_test.go index 4b02ff7e3..49cdf7917 100644 --- a/services/agents-api/internal/runtimeenrollment/connection_test.go +++ b/services/agents-api/internal/runtimeenrollment/connection_test.go @@ -3,14 +3,15 @@ package runtimeenrollment import ( "context" "errors" - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" - "github.com/gorilla/websocket" "net/http" "net/http/httptest" "strings" "testing" "time" + "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" + "github.com/gorilla/websocket" + "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/device" "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/gateway" "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" diff --git a/services/agents-api/internal/runtimeobs/operations_test.go b/services/agents-api/internal/runtimeobs/operations_test.go index 50f30ea98..45dfed813 100644 --- a/services/agents-api/internal/runtimeobs/operations_test.go +++ b/services/agents-api/internal/runtimeobs/operations_test.go @@ -3,9 +3,10 @@ package runtimeobs import ( "context" "errors" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/providercontract" "testing" "time" + + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/providercontract" ) type failingBatchSource struct { diff --git a/services/agents-api/internal/runtimeobs/service.go b/services/agents-api/internal/runtimeobs/service.go index 08188e606..d44f56408 100644 --- a/services/agents-api/internal/runtimeobs/service.go +++ b/services/agents-api/internal/runtimeobs/service.go @@ -4,11 +4,12 @@ import ( "context" "errors" "fmt" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/providercontract" "reflect" "regexp" "sync" "time" + + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/providercontract" ) var providerTypePattern = regexp.MustCompile(`^[a-z][a-z0-9_]{0,31}$`) diff --git a/services/agents-api/internal/sandbox/configuration.go b/services/agents-api/internal/sandbox/configuration.go new file mode 100644 index 000000000..700c8facb --- /dev/null +++ b/services/agents-api/internal/sandbox/configuration.go @@ -0,0 +1,101 @@ +package sandbox + +import ( + "bytes" + "context" + "encoding/json" + "io" + "slices" + + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/providercontract" +) + +// Requirement has no implicit default: registration must choose either value. +type Requirement string + +const ( + Required Requirement = "required" + NotRequired Requirement = "not_required" +) + +type ConfigurationRequirements struct { + Credential Requirement + PublicOrigin Requirement + Discovery providercontract.Support +} + +// Configuration is an adapter-owned typed value, never a request or response DTO. +// Implementations must exclude secrets from JSON and safe diagnostic output. +type Configuration interface { + HasCredential() bool + ReplacesCredential() bool +} + +// ConfigurationRecord separates public selectors, read-only observations and +// secret bytes. Store encrypts Secret with the installation and generation. +// Only adapter codecs may produce Public and Metadata; neither is input passthrough. +type ConfigurationRecord struct { + Public json.RawMessage + Metadata json.RawMessage + Secret []byte `json:"-"` +} + +// ConfigurationAdapter owns all interpretation of provider configuration. +// Decode loads retained ownership without remote discovery or new-build admission. +// Normalize validates a candidate; ResolveChange first applies omitted-field +// inheritance, then normalizes. Equal compares normalized identity, excluding +// discovery metadata and explicit credential-submission intent. +type ConfigurationAdapter interface { + Requirements() ConfigurationRequirements + DecodeInput(public, credential json.RawMessage) (Configuration, error) + Encode(Configuration) (ConfigurationRecord, error) + Decode(ConfigurationRecord) (Configuration, error) + Normalize(Selection) (Selection, error) + ResolveChange(next, previous Selection) (Selection, error) + WithCredential(owner, candidate Configuration) (Configuration, error) + Equal(a, b Configuration) (bool, error) +} + +// ConfigurationDiscoveryInput is a transient read-only request. Query is typed +// and validated by the adapter; it cannot select a compute mutation. +type ConfigurationDiscoveryInput struct { + Configuration json.RawMessage `json:"configuration" swaggertype:"object"` + Credential json.RawMessage `json:"credential" swaggertype:"object"` + Query json.RawMessage `json:"query" swaggertype:"object"` +} + +// ConfigurationDiscoverer is separate from compute and candidate admission. +// Support must also be explicitly declared in ConfigurationRequirements. +type ConfigurationDiscoverer interface { + DiscoverConfiguration(context.Context, ConfigurationDiscoveryInput) (json.RawMessage, error) +} + +// DecodeConfigurationObject rejects unknown fields, null, nonobjects and trailing +// input without exposing submitted content in its error. Missing objects are empty. +func DecodeConfigurationObject(raw json.RawMessage, target any, allowed ...string) error { + if len(raw) == 0 { + raw = json.RawMessage(`{}`) + } + raw = bytes.TrimSpace(raw) + if len(raw) == 0 || raw[0] != '{' { + return ErrInvalid + } + var fields map[string]json.RawMessage + if json.Unmarshal(raw, &fields) != nil || fields == nil { + return ErrInvalid + } + for field := range fields { + if !slices.Contains(allowed, field) { + return ErrInvalid + } + } + decoder := json.NewDecoder(bytes.NewReader(raw)) + decoder.DisallowUnknownFields() + if decoder.Decode(target) != nil { + return ErrInvalid + } + if err := decoder.Decode(new(any)); err != io.EOF { + return ErrInvalid + } + return nil +} diff --git a/services/agents-api/internal/sandbox/configuration_errors.go b/services/agents-api/internal/sandbox/configuration_errors.go new file mode 100644 index 000000000..7c02d9728 --- /dev/null +++ b/services/agents-api/internal/sandbox/configuration_errors.go @@ -0,0 +1,24 @@ +package sandbox + +// ConfigurationError is a fixed safe diagnostic, never SDK text or submitted data. +// Class describes the request outcome; it does not authorize replay. +type ConfigurationError struct { + Class ConfigurationErrorClass + Code, Param, Message string +} +type ConfigurationErrorClass string + +const ( + ConfigurationInvalid ConfigurationErrorClass = "invalid" + ConfigurationConflict ConfigurationErrorClass = "conflict" + ConfigurationUnconfirmed ConfigurationErrorClass = "unconfirmed" +) + +func (e *ConfigurationError) Error() string { return e.Message } + +var ( + ErrCredentialRejected = &ConfigurationError{ConfigurationInvalid, "sandbox_credential_invalid", "credential", "The sandbox provider credential was rejected."} + ErrCredentialOwnership = &ConfigurationError{ConfigurationConflict, "sandbox_credential_ownership", "credential", "The credential cannot manage the retained deployment. Reset before changing accounts."} + ErrConfigurationUnconfirmed = &ConfigurationError{ConfigurationUnconfirmed, "sandbox_verification_unconfirmed", "", "Sandbox provider verification could not be confirmed."} + ErrConfigurationSelection = &ConfigurationError{ConfigurationInvalid, "sandbox_configuration_invalid", "configuration", "Select a ready immutable provider configuration with matching resources."} +) diff --git a/services/agents-api/internal/sandbox/contracttest/provider.go b/services/agents-api/internal/sandbox/contracttest/provider.go index 8c7555f53..8201316c8 100644 --- a/services/agents-api/internal/sandbox/contracttest/provider.go +++ b/services/agents-api/internal/sandbox/contracttest/provider.go @@ -5,11 +5,12 @@ package contracttest import ( "context" "errors" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/providercontract" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/sandbox" "reflect" "testing" "time" + + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/providercontract" + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/sandbox" ) type Fault string diff --git a/services/agents-api/internal/sandbox/e2b/configuration.go b/services/agents-api/internal/sandbox/e2b/configuration.go new file mode 100644 index 000000000..fc24bd871 --- /dev/null +++ b/services/agents-api/internal/sandbox/e2b/configuration.go @@ -0,0 +1,128 @@ +package e2b + +import ( + "encoding/json" + "reflect" + + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/providercontract" + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/sandbox" +) + +type ConfigurationAdapter struct{} +type publicConfiguration struct { + Template string `json:"template"` + APIURL string `json:"api_url"` + Domain string `json:"domain"` +} +type buildMetadata struct { + TemplateBuild buildView `json:"template_build"` +} +type buildView struct { + Status *string `json:"status"` + Resources buildResources `json:"resources"` +} +type buildResources struct { + CPUs *int32 `json:"cpus"` + MemoryMiB *int32 `json:"memory_mib"` + RootDiskMiB *int32 `json:"root_disk_mib"` +} + +func configuration(s sandbox.Selection) *DeploymentConfiguration { + c, _ := s.Configuration.(*DeploymentConfiguration) + return c +} +func (ConfigurationAdapter) Requirements() sandbox.ConfigurationRequirements { + return sandbox.ConfigurationRequirements{Credential: sandbox.Required, PublicOrigin: sandbox.Required, Discovery: providercontract.Support{State: providercontract.Supported}} +} +func (ConfigurationAdapter) DecodeInput(public, secret json.RawMessage) (sandbox.Configuration, error) { + var p publicConfiguration + if err := sandbox.DecodeConfigurationObject(public, &p, "template", "api_url", "domain"); err != nil { + return nil, err + } + c := &DeploymentConfiguration{Template: p.Template, APIURL: p.APIURL, Domain: p.Domain} + if len(secret) > 0 { + var credential struct { + APIKey string `json:"api_key"` + } + if err := sandbox.DecodeConfigurationObject(secret, &credential, "api_key"); err != nil { + return nil, err + } + if credential.APIKey == "" { + return nil, sandbox.ErrInvalid + } + c.APIKey, c.CredentialSupplied = credential.APIKey, true + } + return c, nil +} +func (ConfigurationAdapter) Encode(value sandbox.Configuration) (sandbox.ConfigurationRecord, error) { + c, ok := value.(*DeploymentConfiguration) + if !ok || c == nil { + return sandbox.ConfigurationRecord{}, sandbox.ErrInvalid + } + public, err := json.Marshal(publicConfiguration{Template: c.Template, APIURL: c.APIURL, Domain: c.Domain}) + if err != nil { + return sandbox.ConfigurationRecord{}, sandbox.ErrInvalid + } + m := c.metadata + if m != nil { + copy := *m + m = © + } + if b := c.TemplateBuild; b != nil { + m = &buildMetadata{TemplateBuild: buildView{Status: &b.Status, Resources: buildResources{CPUs: &b.CPUs, MemoryMiB: &b.MemoryMiB, RootDiskMiB: b.RootDiskMiB}}} + } + var metadata json.RawMessage + if m != nil { + metadata, _ = json.Marshal(m) + } + return sandbox.ConfigurationRecord{Public: public, Metadata: metadata, Secret: []byte(c.APIKey)}, nil +} +func (ConfigurationAdapter) Decode(record sandbox.ConfigurationRecord) (sandbox.Configuration, error) { + var p publicConfiguration + var m buildMetadata + if sandbox.DecodeConfigurationObject(record.Public, &p, "template", "api_url", "domain") != nil || sandbox.DecodeConfigurationObject(record.Metadata, &m, "template_build") != nil { + return nil, sandbox.ErrInvalid + } + c := &DeploymentConfiguration{Template: p.Template, APIURL: p.APIURL, Domain: p.Domain, APIKey: string(record.Secret)} + var err error + c.APIURL, c.Domain, err = NormalizeEndpoint(c.APIURL, c.Domain) + if err != nil { + return nil, sandbox.ErrInvalid + } + if string(record.Metadata) != "{}" && len(record.Metadata) > 0 { + c.metadata = &m + } + b := m.TemplateBuild + if b.Status != nil && b.Resources.CPUs != nil && b.Resources.MemoryMiB != nil { + c.TemplateBuild = &DeploymentBuild{Status: *b.Status, CPUs: *b.Resources.CPUs, MemoryMiB: *b.Resources.MemoryMiB, RootDiskMiB: b.Resources.RootDiskMiB} + } + return c, nil +} +func (ConfigurationAdapter) Normalize(s sandbox.Selection) (sandbox.Selection, error) { + return NormalizeSelection(s) +} +func (a ConfigurationAdapter) ResolveChange(next, previous sandbox.Selection) (sandbox.Selection, error) { + return a.Normalize(ResolveChange(next, previous)) +} +func (ConfigurationAdapter) WithCredential(owner, candidate sandbox.Configuration) (sandbox.Configuration, error) { + a, ok := owner.(*DeploymentConfiguration) + b, ok2 := candidate.(*DeploymentConfiguration) + if !ok || !ok2 || a == nil || b == nil || !b.HasCredential() { + return nil, sandbox.ErrInvalid + } + c := *a + c.APIKey = b.APIKey + return &c, nil +} +func (ConfigurationAdapter) Equal(a, b sandbox.Configuration) (bool, error) { + x, ok := a.(*DeploymentConfiguration) + y, ok2 := b.(*DeploymentConfiguration) + if !ok || !ok2 || x == nil || y == nil { + return false, sandbox.ErrInvalid + } + xc, yc := *x, *y + xc.TemplateBuild, yc.TemplateBuild = nil, nil + xc.metadata, yc.metadata = nil, nil + xc.CredentialSupplied, yc.CredentialSupplied = false, false + return reflect.DeepEqual(xc, yc), nil +} diff --git a/services/agents-api/internal/sandbox/e2b/configuration_discovery.go b/services/agents-api/internal/sandbox/e2b/configuration_discovery.go new file mode 100644 index 000000000..68e4b98e9 --- /dev/null +++ b/services/agents-api/internal/sandbox/e2b/configuration_discovery.go @@ -0,0 +1,48 @@ +package e2b + +import ( + "context" + "encoding/json" + "errors" + "os" + "time" + + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/sandbox" +) + +func (ConfigurationAdapter) DiscoverConfiguration(ctx context.Context, input sandbox.ConfigurationDiscoveryInput) (json.RawMessage, error) { + var connection struct { + APIURL string `json:"api_url"` + Domain string `json:"domain"` + } + var credential struct { + APIKey string `json:"api_key"` + } + var query struct { + Template string `json:"template,omitempty"` + } + if sandbox.DecodeConfigurationObject(input.Configuration, &connection, "api_url", "domain") != nil || sandbox.DecodeConfigurationObject(input.Credential, &credential, "api_key") != nil || sandbox.DecodeConfigurationObject(input.Query, &query, "template") != nil { + return nil, sandbox.ErrInvalid + } + binary := os.Getenv("OAC_E2B_PROVIDER_BIN") + if binary == "" { + binary = "/opt/oac/e2b/oac-e2b-provider" + } + ctx, cancel := context.WithTimeout(ctx, 30*time.Second) + defer cancel() + out, err := Discover(ctx, &ProcessCaller{}, binary, credential.APIKey, connection.APIURL, connection.Domain, query.Template) + if err != nil { + if errors.Is(err, sandbox.ErrInvalid) { + return nil, sandbox.ErrConfigurationSelection + } + return nil, sandbox.ErrConfigurationUnconfirmed + } + if query.Template == "" { + return json.Marshal(struct { + Templates []TemplateSummary `json:"templates"` + }{out.Templates}) + } + return json.Marshal(struct { + Builds []ReadyBuild `json:"builds"` + }{out.Builds}) +} diff --git a/services/agents-api/internal/sandbox/e2b/configuration_types.go b/services/agents-api/internal/sandbox/e2b/configuration_types.go new file mode 100644 index 000000000..f2c3f981e --- /dev/null +++ b/services/agents-api/internal/sandbox/e2b/configuration_types.go @@ -0,0 +1,28 @@ +package e2b + +// APIKey is internal configuration. HTTP requests use a write-only DTO. +// DeploymentBuild is set only by Core after it validates the candidate. +type DeploymentConfiguration struct { + metadata *buildMetadata + CredentialSupplied bool `json:"-"` + APIKey string `json:"-"` + APIURL string `json:"api_url,omitempty"` + Domain string `json:"domain,omitempty"` + Template string `json:"template"` + TemplateBuild *DeploymentBuild `json:"-"` +} + +// DeploymentBuild is the fixed build as read by the validation that +// admitted a selection. RootDiskMiB is nil when E2B does not report it. +type DeploymentBuild struct { + Status string + CPUs, MemoryMiB int32 + RootDiskMiB *int32 +} + +func (c *DeploymentConfiguration) HasCredential() bool { return c != nil && c.APIKey != "" } +func (c *DeploymentConfiguration) ReplacesCredential() bool { return c != nil && c.CredentialSupplied } +func (c *DeploymentConfiguration) String() string { return "E2B deployment configuration (private)" } + +// MarshalJSON blocks accidental serialization; ConfigurationAdapter.Encode owns the public projection. +func (*DeploymentConfiguration) MarshalJSON() ([]byte, error) { return []byte(`{}`), nil } diff --git a/services/agents-api/internal/sandbox/e2b/contract_test.go b/services/agents-api/internal/sandbox/e2b/contract_test.go index f4d55e827..88be73a8d 100644 --- a/services/agents-api/internal/sandbox/e2b/contract_test.go +++ b/services/agents-api/internal/sandbox/e2b/contract_test.go @@ -3,10 +3,11 @@ package e2b import ( "context" "errors" + "testing" + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/sandbox" "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/sandbox/contracttest" "github.com/google/uuid" - "testing" ) type contractCaller func(context.Context, Request) (Response, error) diff --git a/services/agents-api/internal/sandbox/e2b/credential.go b/services/agents-api/internal/sandbox/e2b/credential.go index f7d795c6e..c67eee4b9 100644 --- a/services/agents-api/internal/sandbox/e2b/credential.go +++ b/services/agents-api/internal/sandbox/e2b/credential.go @@ -2,15 +2,10 @@ package e2b import ( "context" - "errors" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/sandbox" "time" -) -var ErrRequestUnconfirmed = errors.New("E2B verification could not be confirmed") -var ErrTemplateInvalid = errors.New("E2B template build rejected") -var ErrCredentialInvalid = errors.New("E2B credential rejected") -var ErrTeamMismatch = errors.New("E2B team does not own the retained sandbox deployment") + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/sandbox" +) // VerifyCredential is read-only and bounded. A public readable template alone // does not prove team ownership. References are one bounded Core-owned page. @@ -28,17 +23,17 @@ func (p *Provider) VerifyCredential(ctx context.Context, refs []sandbox.Referenc deadline, _ := ctx.Deadline() out, err := p.caller.Call(ctx, Request{Version: ProtocolVersion, Operation: "verify_credential", Config: p.config, References: refs, Deadline: deadline}) if err != nil || out.Version != ProtocolVersion { - return ErrRequestUnconfirmed + return sandbox.ErrConfigurationUnconfirmed } switch out.ErrorCode { case "unauthorized": - return ErrCredentialInvalid + return sandbox.ErrCredentialRejected case "team_mismatch", "invalid": - return ErrTeamMismatch + return sandbox.ErrCredentialOwnership case "": if out.DeploymentValid && out.Info == nil && out.Command == nil && out.Observations == nil && out.TemplateBuild == nil { return nil } } - return ErrRequestUnconfirmed + return sandbox.ErrConfigurationUnconfirmed } diff --git a/services/agents-api/internal/sandbox/e2b/deployment.go b/services/agents-api/internal/sandbox/e2b/deployment.go index 97eb36898..3370a26eb 100644 --- a/services/agents-api/internal/sandbox/e2b/deployment.go +++ b/services/agents-api/internal/sandbox/e2b/deployment.go @@ -18,7 +18,7 @@ func Policy() sandbox.DeploymentPolicy { func ValidateResources(r sandbox.Resources) error { return r.ValidatePolicy("e2b", Policy()) } func ValidateSpecification(s sandbox.DeploymentSpec) error { return s.ValidatePolicy("e2b", Policy()) } -func ValidateConfiguration(c *sandbox.E2BConfiguration) error { +func ValidateConfiguration(c *DeploymentConfiguration) error { if c == nil || c.APIKey == "" || len(c.APIKey) > 4096 || strings.IndexFunc(c.APIKey, func(r rune) bool { return unicode.IsSpace(r) || r == 0 }) >= 0 { return sandbox.ErrInvalid } @@ -48,23 +48,23 @@ func NormalizeSelection(s sandbox.Selection) (sandbox.Selection, error) { } else if err := ValidateSpecification(s.DeploymentSpec); err != nil { return s, err } - if err := ValidateConfiguration(s.E2B); err != nil { + if err := ValidateConfiguration(configuration(s)); err != nil { return s, err } - c := *s.E2B + c := *configuration(s) c.APIURL, c.Domain, _ = NormalizeEndpoint(c.APIURL, c.Domain) - s.E2B = &c + s.Configuration = &c return s, nil } -func WithTemplateBuild(input sandbox.Selection, build *sandbox.TemplateBuild) sandbox.Selection { - if input.E2B != nil && build != nil { - c, b := *input.E2B, *build +func WithTemplateBuild(input sandbox.Selection, build *DeploymentBuild) sandbox.Selection { + if configuration(input) != nil && build != nil { + c, b := *configuration(input), *build if input.Resources == (sandbox.Resources{}) { input.Resources = sandbox.Resources{CPUs: uint32(b.CPUs), MemoryMiB: uint32(b.MemoryMiB)} } c.TemplateBuild = &b - input.E2B = &c + input.Configuration = &c } return input } @@ -73,15 +73,15 @@ func WithTemplateBuild(input sandbox.Selection, build *sandbox.TemplateBuild) sa func (p *Provider) DiscoverSelection(ctx context.Context, s sandbox.Selection) (sandbox.Selection, error) { build, err := p.ValidateDeployment(ctx) if err != nil { - if errors.Is(err, ErrCredentialInvalid) || errors.Is(err, ErrTeamMismatch) { + if errors.Is(err, sandbox.ErrCredentialRejected) || errors.Is(err, sandbox.ErrCredentialOwnership) { return s, err } if errors.Is(err, sandbox.ErrInvalid) { - return s, ErrTemplateInvalid + return s, sandbox.ErrConfigurationSelection } - return s, ErrRequestUnconfirmed + return s, sandbox.ErrConfigurationUnconfirmed } - recorded := &sandbox.TemplateBuild{Status: build.Status, CPUs: int32(build.CPUs), MemoryMiB: int32(build.MemoryMiB)} + recorded := &DeploymentBuild{Status: build.Status, CPUs: int32(build.CPUs), MemoryMiB: int32(build.MemoryMiB)} if build.RootDiskMiB != nil && *build.RootDiskMiB <= math.MaxInt32 { disk := int32(*build.RootDiskMiB) recorded.RootDiskMiB = &disk @@ -93,45 +93,21 @@ func (p *Provider) DiscoverSelection(ctx context.Context, s sandbox.Selection) ( return s, nil } -// RestoreSelection normalizes stored connection fields without admitting a new -// template or requiring remote availability for retained-resource cleanup. -func RestoreSelection(s sandbox.Selection) (sandbox.Selection, error) { - if s.E2B == nil { - return s, sandbox.ErrInvalid - } - c := *s.E2B - var err error - c.APIURL, c.Domain, err = NormalizeEndpoint(c.APIURL, c.Domain) - s.E2B = &c - return s, err -} func ResolveChange(next, previous sandbox.Selection) sandbox.Selection { - if next.E2B == nil || previous.E2B == nil { + if configuration(next) == nil || configuration(previous) == nil { return next } - c := *next.E2B - c.ReplaceCredential = c.ReplaceCredential || c.APIKey != "" + c := *configuration(next) + c.CredentialSupplied = c.CredentialSupplied || c.APIKey != "" if c.APIURL == "" && c.Domain == "" { - c.APIURL, c.Domain = previous.E2B.APIURL, previous.E2B.Domain + c.APIURL, c.Domain = configuration(previous).APIURL, configuration(previous).Domain } - if c.APIKey == "" && !c.ReplaceCredential { - c.APIKey = previous.E2B.APIKey + if c.APIKey == "" && !c.CredentialSupplied { + c.APIKey = configuration(previous).APIKey } - if c.Template == previous.E2B.Template && next.Resources == (sandbox.Resources{}) { + if c.Template == configuration(previous).Template && next.Resources == (sandbox.Resources{}) { next.Resources = previous.Resources } - next.E2B = &c + next.Configuration = &c return next } - -func ReplaceCredential(owner, candidate sandbox.Selection) sandbox.Selection { - if owner.E2B != nil && candidate.E2B != nil { - c := *owner.E2B - c.APIKey = candidate.E2B.APIKey - owner.E2B = &c - } - return owner -} -func CredentialRequiresReset(err error) bool { - return errors.Is(err, ErrCredentialInvalid) || errors.Is(err, ErrTeamMismatch) -} diff --git a/services/agents-api/internal/sandbox/e2b/provider.go b/services/agents-api/internal/sandbox/e2b/provider.go index 8d50db340..f26e6a958 100644 --- a/services/agents-api/internal/sandbox/e2b/provider.go +++ b/services/agents-api/internal/sandbox/e2b/provider.go @@ -163,7 +163,7 @@ func (c Config) Validate() error { if !validID(c.InstallationID) || c.TimeoutSeconds < 1 || c.TimeoutSeconds > 86400 { return sandbox.ErrInvalid } - if err := ValidateConfiguration(&sandbox.E2BConfiguration{APIKey: c.APIKey, Template: c.Template, APIURL: c.APIURL, Domain: c.Domain}); err != nil { + if err := ValidateConfiguration(&DeploymentConfiguration{APIKey: c.APIKey, Template: c.Template, APIURL: c.APIURL, Domain: c.Domain}); err != nil { return err } for _, path := range []string{c.Binary, c.StateDir} { @@ -227,9 +227,9 @@ func (p *Provider) call(ctx context.Context, operation string, r sandbox.Referen case "template_invalid": return out, fmt.Errorf("%w: This E2B template lacks the current Runtime startup entry point. Build a template with this release's build-template.py and select it in the sandbox deployment.", sandbox.ErrInvalid) case "team_mismatch": - return out, ErrTeamMismatch + return out, sandbox.ErrCredentialOwnership case "unauthorized": - return out, ErrCredentialInvalid + return out, sandbox.ErrCredentialRejected case "invalid": return out, sandbox.ErrInvalid case "ownership": diff --git a/services/agents-api/internal/sandbox/e2b/selection_test.go b/services/agents-api/internal/sandbox/e2b/selection_test.go index b225eb7e5..80cdba4f4 100644 --- a/services/agents-api/internal/sandbox/e2b/selection_test.go +++ b/services/agents-api/internal/sandbox/e2b/selection_test.go @@ -7,13 +7,13 @@ import ( ) func TestOmittedE2BResourcesComeFromValidatedTemplateBuild(t *testing.T) { - request := sandbox.Selection{Provider: "e2b", E2B: &sandbox.E2BConfiguration{APIKey: "key", Template: "runtime:build"}} - build := &sandbox.TemplateBuild{Status: "ready", CPUs: 4, MemoryMiB: 4096} + request := sandbox.Selection{Provider: "e2b", Configuration: &DeploymentConfiguration{APIKey: "key", Template: "runtime:build"}} + build := &DeploymentBuild{Status: "ready", CPUs: 4, MemoryMiB: 4096} saved := WithTemplateBuild(request, build) - if saved.Resources != (sandbox.Resources{CPUs: 4, MemoryMiB: 4096}) || saved.E2B.TemplateBuild == nil || *saved.E2B.TemplateBuild != *build { + if saved.Resources != (sandbox.Resources{CPUs: 4, MemoryMiB: 4096}) || saved.Configuration.(*DeploymentConfiguration).TemplateBuild == nil || *saved.Configuration.(*DeploymentConfiguration).TemplateBuild != *build { t.Fatalf("validated build was not saved with the selection: %+v", saved) } - if request.Resources != (sandbox.Resources{}) || request.E2B.TemplateBuild != nil { + if request.Resources != (sandbox.Resources{}) || request.Configuration.(*DeploymentConfiguration).TemplateBuild != nil { t.Fatal("caller's request was changed") } request.Resources = sandbox.Resources{CPUs: 2, MemoryMiB: 2048} diff --git a/services/agents-api/internal/sandbox/microsandbox/contract_test.go b/services/agents-api/internal/sandbox/microsandbox/contract_test.go index 3a11cb15d..97f7a9ba9 100644 --- a/services/agents-api/internal/sandbox/microsandbox/contract_test.go +++ b/services/agents-api/internal/sandbox/microsandbox/contract_test.go @@ -3,10 +3,11 @@ package microsandbox import ( "context" "errors" + "testing" + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/sandbox" "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/sandbox/contracttest" "github.com/google/uuid" - "testing" ) func TestProviderContract(t *testing.T) { diff --git a/services/agents-api/internal/sandbox/microsandbox/process.go b/services/agents-api/internal/sandbox/microsandbox/process.go index 7600b386c..c81f2591c 100644 --- a/services/agents-api/internal/sandbox/microsandbox/process.go +++ b/services/agents-api/internal/sandbox/microsandbox/process.go @@ -5,7 +5,6 @@ import ( "context" "encoding/json" "errors" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/sandbox" "io" "os" "os/exec" @@ -13,6 +12,8 @@ import ( "strings" "sync/atomic" "syscall" + + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/sandbox" ) // ProcessCaller never kills a mutating helper on a Core response timeout. diff --git a/services/agents-api/internal/sandbox/microsandbox/process_test.go b/services/agents-api/internal/sandbox/microsandbox/process_test.go index 6d60cda93..1813b36b7 100644 --- a/services/agents-api/internal/sandbox/microsandbox/process_test.go +++ b/services/agents-api/internal/sandbox/microsandbox/process_test.go @@ -5,7 +5,6 @@ import ( "encoding/json" "errors" "fmt" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/sandbox" "os" "os/exec" "path/filepath" @@ -13,6 +12,8 @@ import ( "syscall" "testing" "time" + + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/sandbox" ) func TestMain(m *testing.M) { diff --git a/services/agents-api/internal/sandbox/node/agent.go b/services/agents-api/internal/sandbox/node/agent.go index 4883979c3..51b19a129 100644 --- a/services/agents-api/internal/sandbox/node/agent.go +++ b/services/agents-api/internal/sandbox/node/agent.go @@ -3,7 +3,6 @@ package node import ( "context" "errors" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/sandbox/providers" "math/rand/v2" "net/http" "net/url" @@ -12,6 +11,8 @@ import ( "sync/atomic" "time" + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/sandbox/providers" + "github.com/MiniMax-AI-Dev/parsar/internal/obs/log" "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/sandbox" "github.com/gorilla/websocket" diff --git a/services/agents-api/internal/sandbox/node/observations_test.go b/services/agents-api/internal/sandbox/node/observations_test.go index c41ab6a2a..a234fdd66 100644 --- a/services/agents-api/internal/sandbox/node/observations_test.go +++ b/services/agents-api/internal/sandbox/node/observations_test.go @@ -4,13 +4,14 @@ import ( "context" "encoding/json" "errors" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/providercontract" "net/http/httptest" "reflect" "sync" "testing" "time" + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/providercontract" + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/runtimeobs" "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/sandbox" "github.com/google/uuid" diff --git a/services/agents-api/internal/sandbox/node/operations_test.go b/services/agents-api/internal/sandbox/node/operations_test.go index 67bbd6292..d59c915fc 100644 --- a/services/agents-api/internal/sandbox/node/operations_test.go +++ b/services/agents-api/internal/sandbox/node/operations_test.go @@ -4,10 +4,11 @@ import ( "context" "encoding/json" "errors" + "testing" + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/providercontract" "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/sandbox" "github.com/google/uuid" - "testing" ) func TestUnsupportedWireIsExplicitAndDoesNotInvokeProvider(t *testing.T) { diff --git a/services/agents-api/internal/sandbox/node/recovery_test.go b/services/agents-api/internal/sandbox/node/recovery_test.go index aa7db298b..c96558f4d 100644 --- a/services/agents-api/internal/sandbox/node/recovery_test.go +++ b/services/agents-api/internal/sandbox/node/recovery_test.go @@ -3,9 +3,6 @@ package node import ( "context" "errors" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/sandbox" - "github.com/google/uuid" - "github.com/gorilla/websocket" "net/http" "net/http/httptest" "os" @@ -14,6 +11,10 @@ import ( "sync" "testing" "time" + + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/sandbox" + "github.com/google/uuid" + "github.com/gorilla/websocket" ) func TestCoreRestartFencesOldConnectionAndNodeRestartKeepsIdentity(t *testing.T) { diff --git a/services/agents-api/internal/sandbox/node/wire.go b/services/agents-api/internal/sandbox/node/wire.go index 53a34f2c2..6ad5bb62a 100644 --- a/services/agents-api/internal/sandbox/node/wire.go +++ b/services/agents-api/internal/sandbox/node/wire.go @@ -7,10 +7,11 @@ import ( "context" "encoding/json" "errors" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/providercontract" "io" "time" + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/providercontract" + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/runtimeobs" "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/sandbox" "github.com/google/uuid" diff --git a/services/agents-api/internal/sandbox/operations.go b/services/agents-api/internal/sandbox/operations.go index 6c781cb62..73a54e61d 100644 --- a/services/agents-api/internal/sandbox/operations.go +++ b/services/agents-api/internal/sandbox/operations.go @@ -3,9 +3,10 @@ package sandbox import ( "errors" "fmt" + "reflect" + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/providercontract" "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/runtimeobs" - "reflect" ) // These existing interfaces are the canonical operation inventory. Declarations diff --git a/services/agents-api/internal/sandbox/operations_test.go b/services/agents-api/internal/sandbox/operations_test.go index 93cb1320a..1af01d0a5 100644 --- a/services/agents-api/internal/sandbox/operations_test.go +++ b/services/agents-api/internal/sandbox/operations_test.go @@ -3,13 +3,14 @@ package sandbox_test import ( "context" "errors" + "reflect" + "testing" + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/providercontract" "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/sandbox" "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/sandbox/docker" "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/sandbox/e2b" "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/sandbox/microsandbox" - "reflect" - "testing" ) type changedDeclaration struct { diff --git a/services/agents-api/internal/sandbox/providers/configuration.go b/services/agents-api/internal/sandbox/providers/configuration.go new file mode 100644 index 000000000..909e532ee --- /dev/null +++ b/services/agents-api/internal/sandbox/providers/configuration.go @@ -0,0 +1,143 @@ +package providers + +import ( + "context" + "encoding/json" + + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/providercontract" + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/sandbox" +) + +func DecodeInput(kind string, public, credential json.RawMessage) (sandbox.Configuration, error) { + a, err := Lookup(kind) + if err != nil { + return nil, err + } + return a.Configuration.DecodeInput(public, credential) +} +func Encode(kind string, c sandbox.Configuration) (sandbox.ConfigurationRecord, error) { + a, err := Lookup(kind) + if err != nil { + return sandbox.ConfigurationRecord{}, err + } + return a.Configuration.Encode(c) +} +func Decode(kind string, r sandbox.ConfigurationRecord) (sandbox.Configuration, error) { + a, err := Lookup(kind) + if err != nil { + return nil, err + } + return a.Configuration.Decode(r) +} +func Equal(kind string, a, b sandbox.Configuration) (bool, error) { + adapter, err := Lookup(kind) + if err != nil { + return false, err + } + return adapter.Configuration.Equal(a, b) +} +func UsesCredential(kind string) bool { + a, e := Lookup(kind) + return e == nil && a.Configuration.Requirements().Credential == sandbox.Required +} +func RequiresPublicOrigin(kind string) bool { + a, e := Lookup(kind) + return e == nil && a.Configuration.Requirements().PublicOrigin == sandbox.Required +} +func Normalize(s sandbox.Selection) (sandbox.Selection, error) { + a, e := Lookup(s.Provider) + if e != nil { + return s, e + } + return a.Configuration.Normalize(s) +} +func ResolveChange(next, previous sandbox.Selection) (sandbox.Selection, error) { + a, e := Lookup(next.Provider) + if e != nil { + return next, e + } + return a.Configuration.ResolveChange(next, previous) +} +func WithCredential(owner, candidate sandbox.Selection) (sandbox.Selection, error) { + a, e := Lookup(owner.Provider) + if e != nil { + return owner, e + } + if a.Configuration.Requirements().Credential != sandbox.Required { + return owner, &providercontract.UnsupportedError{Operation: "WithCredential", Reason: "credentials_not_required"} + } + owner.Configuration, e = a.Configuration.WithCredential(owner.Configuration, candidate.Configuration) + return owner, e +} +func DiscoverConfiguration(ctx context.Context, kind string, input sandbox.ConfigurationDiscoveryInput) (json.RawMessage, error) { + a, err := Lookup(kind) + if err != nil { + return nil, err + } + if err := a.Configuration.Requirements().Discovery.Check("DiscoverConfiguration"); err != nil { + return nil, err + } + discovery, ok := a.Configuration.(sandbox.ConfigurationDiscoverer) + if !ok { + return nil, providercontract.ErrContract + } + return discovery.DiscoverConfiguration(ctx, input) +} + +type nodeConfiguration struct{} + +func (nodeConfiguration) HasCredential() bool { return false } +func (nodeConfiguration) ReplacesCredential() bool { return false } + +type nodeConfigurationAdapter struct { + validate func(sandbox.DeploymentSpec) error +} + +func (nodeConfigurationAdapter) Requirements() sandbox.ConfigurationRequirements { + return sandbox.ConfigurationRequirements{Credential: sandbox.NotRequired, PublicOrigin: sandbox.NotRequired, Discovery: providercontract.Support{State: providercontract.Unsupported, Reason: "node_configuration_has_no_catalog"}} +} +func (nodeConfigurationAdapter) DecodeInput(public, secret json.RawMessage) (sandbox.Configuration, error) { + if len(secret) > 0 || sandbox.DecodeConfigurationObject(public, &struct{}{}) != nil { + return nil, sandbox.ErrInvalid + } + return nodeConfiguration{}, nil +} +func (nodeConfigurationAdapter) Encode(c sandbox.Configuration) (sandbox.ConfigurationRecord, error) { + if c != nil { + if _, ok := c.(nodeConfiguration); !ok { + return sandbox.ConfigurationRecord{}, sandbox.ErrInvalid + } + } + return sandbox.ConfigurationRecord{Public: json.RawMessage(`{}`), Metadata: json.RawMessage(`{}`)}, nil +} +func (a nodeConfigurationAdapter) Decode(r sandbox.ConfigurationRecord) (sandbox.Configuration, error) { + if len(r.Secret) > 0 || sandbox.DecodeConfigurationObject(r.Metadata, &struct{}{}) != nil { + return nil, sandbox.ErrInvalid + } + return a.DecodeInput(r.Public, nil) +} +func (a nodeConfigurationAdapter) Normalize(s sandbox.Selection) (sandbox.Selection, error) { + if _, err := a.Encode(s.Configuration); err != nil { + return s, err + } + s.Configuration = nodeConfiguration{} + return s, a.validate(s.DeploymentSpec) +} +func (a nodeConfigurationAdapter) ResolveChange(next, previous sandbox.Selection) (sandbox.Selection, error) { + return a.Normalize(next) +} +func (nodeConfigurationAdapter) WithCredential(owner, candidate sandbox.Configuration) (sandbox.Configuration, error) { + return nil, &providercontract.UnsupportedError{Operation: "WithCredential", Reason: "credentials_not_required"} +} +func (a nodeConfigurationAdapter) Equal(x, y sandbox.Configuration) (bool, error) { + if _, err := a.Encode(x); err != nil { + return false, err + } + if _, err := a.Encode(y); err != nil { + return false, err + } + return true, nil +} +func (nodeConfigurationAdapter) DiscoverConfiguration(context.Context, sandbox.ConfigurationDiscoveryInput) (json.RawMessage, error) { + return nil, &providercontract.UnsupportedError{Operation: "DiscoverConfiguration", Reason: "node_configuration_has_no_catalog"} +} diff --git a/services/agents-api/internal/sandbox/providers/deployment_contract_test.go b/services/agents-api/internal/sandbox/providers/deployment_contract_test.go index c016a356a..4f9bc8e20 100644 --- a/services/agents-api/internal/sandbox/providers/deployment_contract_test.go +++ b/services/agents-api/internal/sandbox/providers/deployment_contract_test.go @@ -3,10 +3,11 @@ package providers import ( "bytes" "encoding/json" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/sandbox" "os" "strings" "testing" + + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/sandbox" ) func TestInstallerDeploymentProjectionIsCurrent(t *testing.T) { diff --git a/services/agents-api/internal/sandbox/providers/e2b.go b/services/agents-api/internal/sandbox/providers/e2b.go index 8f7b4f54c..7b453c063 100644 --- a/services/agents-api/internal/sandbox/providers/e2b.go +++ b/services/agents-api/internal/sandbox/providers/e2b.go @@ -32,7 +32,8 @@ func BuildDirect(c DirectConfig) (sandbox.SandboxProvider, error) { return p, nil } func buildE2B(c DirectConfig) (sandbox.SandboxProvider, error) { - if c.Selection.E2B == nil { + configuration, ok := c.Selection.Configuration.(*e2b.DeploymentConfiguration) + if !ok || configuration == nil { return nil, errors.New("E2B deployment configuration is unavailable") } binary := os.Getenv("OAC_E2B_PROVIDER_BIN") @@ -46,8 +47,8 @@ func buildE2B(c DirectConfig) (sandbox.SandboxProvider, error) { resources = &c.Selection.DeploymentSpec.Resources } provider, err := e2b.NewWithCaller(e2b.Config{Binary: binary, StateDir: os.Getenv("OAC_E2B_STATE_DIR"), - Resources: resources, InstallationID: c.InstallationID, APIKey: c.Selection.E2B.APIKey, Template: c.Selection.E2B.Template, - APIURL: c.Selection.E2B.APIURL, Domain: c.Selection.E2B.Domain, TimeoutSeconds: 3600}, &e2b.ProcessCaller{Fence: c.Fence}) + Resources: resources, InstallationID: c.InstallationID, APIKey: configuration.APIKey, Template: configuration.Template, + APIURL: configuration.APIURL, Domain: configuration.Domain, TimeoutSeconds: 3600}, &e2b.ProcessCaller{Fence: c.Fence}) if err != nil { return nil, errors.New("E2B provider cannot load; check the installed helper and private state directory") } diff --git a/services/agents-api/internal/sandbox/providers/operations.go b/services/agents-api/internal/sandbox/providers/operations.go index b6c619c5d..330764e02 100644 --- a/services/agents-api/internal/sandbox/providers/operations.go +++ b/services/agents-api/internal/sandbox/providers/operations.go @@ -1,9 +1,10 @@ package providers import ( + "reflect" + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/providercontract" "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/sandbox" - "reflect" ) // ValidateBinding catches construction that disagrees with its registration. diff --git a/services/agents-api/internal/sandbox/providers/operations_test.go b/services/agents-api/internal/sandbox/providers/operations_test.go index ad305f48a..2bac7d8e5 100644 --- a/services/agents-api/internal/sandbox/providers/operations_test.go +++ b/services/agents-api/internal/sandbox/providers/operations_test.go @@ -2,10 +2,11 @@ package providers import ( "errors" + "testing" + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/providercontract" "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/sandbox/docker" "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/sandbox/e2b" - "testing" ) func TestRegistrationRejectsMissingAndMismatchedDeclarations(t *testing.T) { diff --git a/services/agents-api/internal/sandbox/providers/registry.go b/services/agents-api/internal/sandbox/providers/registry.go index 3591fc145..db2ca3e61 100644 --- a/services/agents-api/internal/sandbox/providers/registry.go +++ b/services/agents-api/internal/sandbox/providers/registry.go @@ -6,6 +6,7 @@ import ( "crypto/sha256" "encoding/hex" "fmt" + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/providercontract" "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/sandbox" @@ -18,41 +19,31 @@ import ( // Native operation support comes from the adapter-owned complete declaration. type Adapter struct { Policy sandbox.DeploymentPolicy - ReplaceCredential func(sandbox.Selection, sandbox.Selection) sandbox.Selection - CredentialRequiresReset func(error) bool - CredentialUnconfirmed error - Restore func(sandbox.Selection) (sandbox.Selection, error) - ResolveChange func(sandbox.Selection, sandbox.Selection) sandbox.Selection + Configuration sandbox.ConfigurationAdapter BuildLocal func(Config, *Built) (func(), error) BuildDirect func(DirectConfig) (sandbox.SandboxProvider, error) - Credential bool - PublicOrigin bool Mode string Operations func() providercontract.Operations IdleSeconds, RetentionSeconds int64 ValidateSpecification func(sandbox.DeploymentSpec) error ValidateResources func(sandbox.Resources) error - Normalize func(sandbox.Selection) (sandbox.Selection, error) } var adapters = map[string]Adapter{ "docker": { Policy: docker.Policy(), Operations: docker.Operations, Mode: "nodes", BuildLocal: buildDocker, ValidateSpecification: docker.ValidateSpecification, ValidateResources: docker.ValidateResources, - Normalize: nodeSelection(docker.ValidateSpecification), + Configuration: nodeConfigurationAdapter{docker.ValidateSpecification}, }, "microsandbox": { Policy: microsandbox.Policy(), Operations: microsandbox.Operations, Mode: "nodes", BuildLocal: buildMicrosandbox, IdleSeconds: 300, RetentionSeconds: 86400, ValidateSpecification: microsandbox.ValidateSpecification, ValidateResources: microsandbox.ValidateResources, - Normalize: nodeSelection(microsandbox.ValidateSpecification), + Configuration: nodeConfigurationAdapter{microsandbox.ValidateSpecification}, }, "e2b": { Policy: e2b.Policy(), Operations: e2b.Operations, Mode: "direct", BuildDirect: buildE2B, - Credential: true, PublicOrigin: true, - ReplaceCredential: e2b.ReplaceCredential, CredentialRequiresReset: e2b.CredentialRequiresReset, - CredentialUnconfirmed: e2b.ErrRequestUnconfirmed, Restore: e2b.RestoreSelection, - ResolveChange: e2b.ResolveChange, Normalize: e2b.NormalizeSelection, + Configuration: e2b.ConfigurationAdapter{}, ValidateSpecification: e2b.ValidateSpecification, ValidateResources: e2b.ValidateResources, }, } @@ -96,21 +87,6 @@ func ValidateResources(kind string, s sandbox.Resources) error { } return a.ValidateResources(s) } -func Normalize(s sandbox.Selection) (sandbox.Selection, error) { - a, e := Lookup(s.Provider) - if e != nil { - return s, e - } - return a.Normalize(s) -} -func nodeSelection(validate func(sandbox.DeploymentSpec) error) func(sandbox.Selection) (sandbox.Selection, error) { - return func(s sandbox.Selection) (sandbox.Selection, error) { - if s.E2B != nil { - return s, sandbox.ErrInvalid - } - return s, validate(s.DeploymentSpec) - } -} // Description is derived once for both preview and persistence. Its fingerprint // identifies a namespace, never mutable capacity or a credential. @@ -132,40 +108,6 @@ func Describe(kind, installation string) (Description, error) { return Description{a.Mode, hex.EncodeToString(digest[:]), a.IdleSeconds, a.RetentionSeconds}, nil } -func UsesCredential(kind string) bool { a, e := Lookup(kind); return e == nil && a.Credential } -func Restore(s sandbox.Selection) (sandbox.Selection, error) { - a, e := Lookup(s.Provider) - if e != nil { - return s, e - } - if a.Restore != nil { - return a.Restore(s) - } - s.E2B = nil - return s, nil -} -func ResolveChange(next, previous sandbox.Selection) (sandbox.Selection, error) { - a, e := Lookup(next.Provider) - if e != nil { - return next, e - } - if a.ResolveChange != nil { - next = a.ResolveChange(next, previous) - } - return Normalize(next) -} - -func WithCredential(owner, candidate sandbox.Selection) (sandbox.Selection, error) { - a, e := Lookup(owner.Provider) - if e != nil { - return owner, e - } - if a.ReplaceCredential == nil { - return owner, sandbox.ErrInvalid - } - return a.ReplaceCredential(owner, candidate), nil -} - // PythonDeploymentContract projects the same registered adapter policies into // the node installer; no second provider list exists in another language. func PythonDeploymentContract() string { diff --git a/services/agents-api/internal/sandbox/providers/registry_test.go b/services/agents-api/internal/sandbox/providers/registry_test.go index 51b99954b..13ba373e5 100644 --- a/services/agents-api/internal/sandbox/providers/registry_test.go +++ b/services/agents-api/internal/sandbox/providers/registry_test.go @@ -2,9 +2,11 @@ package providers import ( "errors" + "testing" + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/sandbox/docker" + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/sandbox/e2b" "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/sandbox/microsandbox" - "testing" "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/sandbox" "github.com/google/uuid" @@ -38,26 +40,26 @@ func TestRegistrationOwnsDeploymentPolicy(t *testing.T) { } func TestSelectionNormalizationAndCredentialInheritance(t *testing.T) { - input := sandbox.Selection{Provider: "e2b", E2B: &sandbox.E2BConfiguration{APIKey: "original-key", Template: "runtime:" + uuid.NewString()}} + input := sandbox.Selection{Provider: "e2b", Configuration: &e2b.DeploymentConfiguration{APIKey: "original-key", Template: "runtime:" + uuid.NewString()}} normalized, err := Normalize(input) - if err != nil || normalized.E2B.APIURL != "https://api.e2b.app" || normalized.E2B.Domain != "e2b.app" { + if err != nil || normalized.Configuration.(*e2b.DeploymentConfiguration).APIURL != "https://api.e2b.app" || normalized.Configuration.(*e2b.DeploymentConfiguration).Domain != "e2b.app" { t.Fatal("defaults not normalized", err) } - if input.E2B.APIURL != "" || input.E2B.Domain != "" { + if input.Configuration.(*e2b.DeploymentConfiguration).APIURL != "" || input.Configuration.(*e2b.DeploymentConfiguration).Domain != "" { t.Fatal("normalization changed request") } normalized.Resources = sandbox.Resources{CPUs: 4, MemoryMiB: 4096} request := input - request.E2B = &sandbox.E2BConfiguration{Template: input.E2B.Template} + request.Configuration = &e2b.DeploymentConfiguration{Template: input.Configuration.(*e2b.DeploymentConfiguration).Template} next, err := ResolveChange(request, normalized) - if err != nil || next.Resources != normalized.Resources || next.E2B.APIKey != "original-key" || next.E2B.APIURL != normalized.E2B.APIURL || next.ReplacesCredential() { + if err != nil || next.Resources != normalized.Resources || next.Configuration.(*e2b.DeploymentConfiguration).APIKey != "original-key" || next.Configuration.(*e2b.DeploymentConfiguration).APIURL != normalized.Configuration.(*e2b.DeploymentConfiguration).APIURL || next.ReplacesCredential() { t.Fatal("omitted values lost committed selection", err) } - request.E2B.ReplaceCredential = true + request.Configuration.(*e2b.DeploymentConfiguration).CredentialSupplied = true if _, err := ResolveChange(request, normalized); !errors.Is(err, sandbox.ErrInvalid) { t.Fatal("explicit empty credential silently inherited", err) } - if request.E2B.APIKey != "" || request.Resources != (sandbox.Resources{}) { + if request.Configuration.(*e2b.DeploymentConfiguration).APIKey != "" || request.Resources != (sandbox.Resources{}) { t.Fatal("resolution changed request") } } @@ -65,7 +67,7 @@ func TestSelectionNormalizationAndCredentialInheritance(t *testing.T) { func TestNewRegistrationDoesNotNeedCoreDispatchChanges(t *testing.T) { const kind = "contract-test-provider" // Registration is test-local: production registrations are fixed, never plugins. - adapters[kind] = Adapter{Mode: "nodes", Operations: docker.Operations, ValidateSpecification: func(sandbox.DeploymentSpec) error { return nil }, Normalize: nodeSelection(func(sandbox.DeploymentSpec) error { return nil })} + adapters[kind] = Adapter{Mode: "nodes", Operations: docker.Operations, ValidateSpecification: func(sandbox.DeploymentSpec) error { return nil }, Configuration: nodeConfigurationAdapter{validate: func(sandbox.DeploymentSpec) error { return nil }}} defer delete(adapters, kind) s, err := Normalize(sandbox.Selection{Provider: kind}) if err != nil || s.Provider != kind || !IsNode(kind) || SupportsCheckpoint(kind) { @@ -75,7 +77,7 @@ func TestNewRegistrationDoesNotNeedCoreDispatchChanges(t *testing.T) { if err != nil || d.Mode != "nodes" || d.IdleSeconds != 0 { t.Fatal(d, err) } - if _, err := Normalize(sandbox.Selection{Provider: kind, E2B: &sandbox.E2BConfiguration{APIKey: "wrong-provider"}}); !errors.Is(err, sandbox.ErrInvalid) { + if _, err := Normalize(sandbox.Selection{Provider: kind, Configuration: &e2b.DeploymentConfiguration{APIKey: "wrong-provider"}}); !errors.Is(err, sandbox.ErrInvalid) { t.Fatal("mixed configuration admitted", err) } } diff --git a/services/agents-api/internal/sandbox/selection.go b/services/agents-api/internal/sandbox/selection.go index 5b42f87e4..65fede7d2 100644 --- a/services/agents-api/internal/sandbox/selection.go +++ b/services/agents-api/internal/sandbox/selection.go @@ -2,36 +2,21 @@ package sandbox import "context" -// APIKey is internal configuration. HTTP requests use a write-only DTO. -// TemplateBuild is set only by Core after it validates the candidate. -type E2BConfiguration struct { - ReplaceCredential bool `json:"-"` - APIKey string `json:"-"` - APIURL string `json:"api_url,omitempty"` - Domain string `json:"domain,omitempty"` - Template string `json:"template"` - TemplateBuild *TemplateBuild `json:"-"` -} - -// TemplateBuild is the fixed build as read by the validation that -// admitted a selection. RootDiskMiB is nil when E2B does not report it. -type TemplateBuild struct { - Status string - CPUs, MemoryMiB int32 - RootDiskMiB *int32 -} - // Selection is the typed deployment configuration shared by preview and commit. type Selection struct { DeploymentSpec - ExpectedGeneration uint64 `json:"expected_generation"` - Provider string `json:"provider"` - E2B *E2BConfiguration `json:"e2b,omitempty"` + ExpectedGeneration uint64 `json:"expected_generation"` + Provider string `json:"provider"` + Configuration Configuration `json:"-"` } -func (s Selection) HasCredential() bool { return s.E2B != nil } +func (s Selection) HasCredential() bool { + return s.Configuration != nil && s.Configuration.HasCredential() +} -func (s Selection) ReplacesCredential() bool { return s.E2B != nil && s.E2B.ReplaceCredential } +func (s Selection) ReplacesCredential() bool { + return s.Configuration != nil && s.Configuration.ReplacesCredential() +} // SelectionDiscoverer is an optional read-only native configuration capability. // It resolves candidate configuration; loading retained ownership never calls it. diff --git a/services/agents-api/internal/store/admin_session_archive.go b/services/agents-api/internal/store/admin_session_archive.go index 3235c8bdc..b9398f39e 100644 --- a/services/agents-api/internal/store/admin_session_archive.go +++ b/services/agents-api/internal/store/admin_session_archive.go @@ -3,9 +3,10 @@ package store import ( "context" "errors" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/adminaudit" "time" + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/adminaudit" + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/db/sqlc" "github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5/pgtype" diff --git a/services/agents-api/internal/store/admin_session_archive_worker_http_test.go b/services/agents-api/internal/store/admin_session_archive_worker_http_test.go index 6b6c102c8..92e55cdf3 100644 --- a/services/agents-api/internal/store/admin_session_archive_worker_http_test.go +++ b/services/agents-api/internal/store/admin_session_archive_worker_http_test.go @@ -11,6 +11,8 @@ import ( "sync" "testing" + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/sandbox/e2b" + "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/device" "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/gateway" "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/adminaudit" @@ -58,7 +60,7 @@ func TestAdminSessionArchiveWorkerHTTPPostgres(t *testing.T) { }) } t.Cleanup(stop) - selection := store.SandboxDeploymentSetupRequest{DeploymentSpec: store.SandboxDeploymentTestSpec("e2b"), Provider: "e2b", E2B: &sandbox.E2BConfiguration{APIKey: "fixture-api-key", Template: "runtime:" + uuid.NewString()}} + selection := store.SandboxDeploymentSetupRequest{DeploymentSpec: store.SandboxDeploymentTestSpec("e2b"), Provider: "e2b", Configuration: &e2b.DeploymentConfiguration{APIKey: "fixture-api-key", Template: "runtime:" + uuid.NewString()}} if _, err := worker.InitializeSandboxDeployment(t.Context(), selection); err != nil { t.Fatal(err) } diff --git a/services/agents-api/internal/store/archive_cancellation_test.go b/services/agents-api/internal/store/archive_cancellation_test.go index 928c17684..5ab181ab3 100644 --- a/services/agents-api/internal/store/archive_cancellation_test.go +++ b/services/agents-api/internal/store/archive_cancellation_test.go @@ -11,13 +11,14 @@ import ( "testing" "time" + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/sandbox/e2b" + "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/device" "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/gateway" "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/adminaudit" "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/execution" "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/runtime" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/sandbox" "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" "github.com/google/uuid" "github.com/gorilla/websocket" @@ -45,7 +46,7 @@ func TestArchiveWaitingCancellationReceipts(t *testing.T) { if err := writer.ClaimWebSandboxDeployment(t.Context(), installation); err != nil { t.Fatal(err) } - if _, err := writer.InitializeSandboxDeployment(t.Context(), installation, store.SandboxDeploymentSetupRequest{DeploymentSpec: store.SandboxDeploymentTestSpec("e2b"), Provider: "e2b", E2B: &sandbox.E2BConfiguration{APIKey: "fixture", Template: "runtime:" + uuid.NewString()}}); err != nil { + if _, err := writer.InitializeSandboxDeployment(t.Context(), installation, store.SandboxDeploymentSetupRequest{DeploymentSpec: store.SandboxDeploymentTestSpec("e2b"), Provider: "e2b", Configuration: &e2b.DeploymentConfiguration{APIKey: "fixture", Template: "runtime:" + uuid.NewString()}}); err != nil { t.Fatal(err) } projectID := uuid.NewString() diff --git a/services/agents-api/internal/store/environment_initialization_test.go b/services/agents-api/internal/store/environment_initialization_test.go index 32d4af3ac..bec908f53 100644 --- a/services/agents-api/internal/store/environment_initialization_test.go +++ b/services/agents-api/internal/store/environment_initialization_test.go @@ -5,6 +5,13 @@ import ( "context" "encoding/json" "errors" + "net/http" + "net/http/httptest" + "strings" + "sync" + "testing" + "time" + "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/device" "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/gateway" "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" @@ -12,12 +19,6 @@ import ( "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/execution" "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/sandbox" "github.com/google/uuid" - "net/http" - "net/http/httptest" - "strings" - "sync" - "testing" - "time" "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" ) diff --git a/services/agents-api/internal/store/environment_templates.go b/services/agents-api/internal/store/environment_templates.go index d12f0cfae..a9e56d8a1 100644 --- a/services/agents-api/internal/store/environment_templates.go +++ b/services/agents-api/internal/store/environment_templates.go @@ -4,11 +4,12 @@ import ( "context" "encoding/json" "errors" - "github.com/MiniMax-AI-Dev/parsar/internal/agentnetwork" - "github.com/MiniMax-AI-Dev/parsar/internal/agentplugin" "time" "unicode/utf8" + "github.com/MiniMax-AI-Dev/parsar/internal/agentnetwork" + "github.com/MiniMax-AI-Dev/parsar/internal/agentplugin" + v1 "github.com/MiniMax-AI-Dev/parsar/contracts/agents-api/v1" "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/db/sqlc" diff --git a/services/agents-api/internal/store/environment_templates_test.go b/services/agents-api/internal/store/environment_templates_test.go index 2b0f6562f..49f70f63a 100644 --- a/services/agents-api/internal/store/environment_templates_test.go +++ b/services/agents-api/internal/store/environment_templates_test.go @@ -2,9 +2,10 @@ package store import ( "errors" - "github.com/google/uuid" "sync" "testing" + + "github.com/google/uuid" ) func TestEnvironmentTemplatesDurabilityIsolationAndConcurrentUpdates(t *testing.T) { diff --git a/services/agents-api/internal/store/export_test.go b/services/agents-api/internal/store/export_test.go index 08a6df914..2ec16100b 100644 --- a/services/agents-api/internal/store/export_test.go +++ b/services/agents-api/internal/store/export_test.go @@ -5,11 +5,12 @@ import ( "context" "encoding/json" + "testing" + v1 "github.com/MiniMax-AI-Dev/parsar/contracts/agents-api/v1" "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/credentialcrypto" "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/identity" "github.com/jackc/pgx/v5/pgxpool" - "testing" ) func NewTestStore(t *testing.T) (*Store, *pgxpool.Pool) { return testStore(t) } diff --git a/services/agents-api/internal/store/function_item_events_test.go b/services/agents-api/internal/store/function_item_events_test.go index 4566d655a..823d60f26 100644 --- a/services/agents-api/internal/store/function_item_events_test.go +++ b/services/agents-api/internal/store/function_item_events_test.go @@ -2,9 +2,10 @@ package store import ( "encoding/json" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/items" "reflect" "testing" + + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/items" ) func TestFunctionResultEventsAreInputs(t *testing.T) { diff --git a/services/agents-api/internal/store/message_input_helpers_test.go b/services/agents-api/internal/store/message_input_helpers_test.go index 4016ca1c7..effa058c5 100644 --- a/services/agents-api/internal/store/message_input_helpers_test.go +++ b/services/agents-api/internal/store/message_input_helpers_test.go @@ -1,8 +1,9 @@ package store_test import ( - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" "testing" + + "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" ) func inputTextForTest(t *testing.T, input proto.MessageInput) string { diff --git a/services/agents-api/internal/store/provider_registration_migration_test.go b/services/agents-api/internal/store/provider_registration_migration_test.go index 1b539a98a..82c8e0bc0 100644 --- a/services/agents-api/internal/store/provider_registration_migration_test.go +++ b/services/agents-api/internal/store/provider_registration_migration_test.go @@ -6,6 +6,8 @@ import ( "strings" "testing" + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/sandbox/e2b" + "github.com/google/uuid" "github.com/jackc/pgx/v5/stdlib" "github.com/pressly/goose/v3" @@ -15,13 +17,13 @@ func TestProviderRegistrationDowngradePreservesCustomEndpoints(t *testing.T) { s, w, view, input := webSpecificationFixture(t, "e2b") tenant, session := managedArchiveSession(t, s, managerSessionInput(uuid.NewString())) archiveAllocation(t, w, tenant, session, view.InstallationID) - input.E2B.APIURL, input.E2B.Domain = "https://api.example.test", "example.test" + input.Configuration.(*e2b.DeploymentConfiguration).APIURL, input.Configuration.(*e2b.DeploymentConfiguration).Domain = "https://api.example.test", "example.test" if _, err := w.UpdateSandboxDeployment(SandboxResetTestContext(t.Context()), view.InstallationID, SandboxDeploymentUpdateRequest{SandboxDeploymentSetupRequest: input, ExpectedGeneration: view.Generation}); err != nil { t.Fatal(err) } tenant, session = managedArchiveSession(t, s, managerSessionInput(uuid.NewString())) archiveAllocation(t, w, tenant, session, view.InstallationID) - input.E2B.Template = "next:" + uuid.NewString() + input.Configuration.(*e2b.DeploymentConfiguration).Template = "next:" + uuid.NewString() if _, err := w.UpdateSandboxDeployment(SandboxResetTestContext(t.Context()), view.InstallationID, SandboxDeploymentUpdateRequest{SandboxDeploymentSetupRequest: input, ExpectedGeneration: 2}); err != nil { t.Fatal(err) } @@ -34,14 +36,14 @@ func TestProviderRegistrationDowngradePreservesCustomEndpoints(t *testing.T) { if _, err := migrations.DownTo(t.Context(), 88); err != nil { t.Fatal(err) } - for generation, want := range map[int]string{1: "", 2: input.E2B.APIURL} { + for generation, want := range map[int]string{1: "", 2: input.Configuration.(*e2b.DeploymentConfiguration).APIURL} { var endpoint string if err := db.QueryRowContext(t.Context(), "SELECT e2b_api_url FROM runtime_deployment_generations WHERE generation=$1", generation).Scan(&endpoint); err != nil || endpoint != want { t.Fatal("downgrade changed endpoint identity", generation, endpoint, err) } } var endpoint string - if err := db.QueryRowContext(t.Context(), "SELECT e2b_api_url FROM runtime_deployment").Scan(&endpoint); err != nil || endpoint != input.E2B.APIURL { + if err := db.QueryRowContext(t.Context(), "SELECT e2b_api_url FROM runtime_deployment").Scan(&endpoint); err != nil || endpoint != input.Configuration.(*e2b.DeploymentConfiguration).APIURL { t.Fatal("downgrade changed current endpoint", endpoint, err) } if _, err := db.ExecContext(t.Context(), "UPDATE runtime_deployment_generations SET e2b_domain='changed.test' WHERE generation=1"); err == nil || !strings.Contains(err.Error(), "Retained sandbox specifications are immutable") { diff --git a/services/agents-api/internal/store/public_url.go b/services/agents-api/internal/store/public_url.go index e93a0f729..a4e97864b 100644 --- a/services/agents-api/internal/store/public_url.go +++ b/services/agents-api/internal/store/public_url.go @@ -8,7 +8,7 @@ import ( // ErrSandboxPublicURLUnreachable rejects E2B selections and new E2B Sessions // while the installation public URL is loopback: E2B sandboxes reach Core from // E2B's cloud. -var ErrSandboxPublicURLUnreachable = errors.New("E2B sandboxes need a reachable HTTPS public URL before they can connect to Core.") +var ErrSandboxPublicURLUnreachable = errors.New("This sandbox provider needs a reachable HTTPS public URL before they can connect to Core.") // SetPublicURL records OAC_PUBLIC_URL, validated by the caller. Core // reports it as the deployment and node configuration core_url and records it diff --git a/services/agents-api/internal/store/runtime_initialization_test.go b/services/agents-api/internal/store/runtime_initialization_test.go index 09089c09d..c52ad26bb 100644 --- a/services/agents-api/internal/store/runtime_initialization_test.go +++ b/services/agents-api/internal/store/runtime_initialization_test.go @@ -6,13 +6,14 @@ import ( "context" "encoding/json" "errors" - "github.com/MiniMax-AI-Dev/parsar/internal/agentplugin" "reflect" "strings" "sync" "testing" "time" + "github.com/MiniMax-AI-Dev/parsar/internal/agentplugin" + v1 "github.com/MiniMax-AI-Dev/parsar/contracts/agents-api/v1" "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/credentialcrypto" diff --git a/services/agents-api/internal/store/runtime_node_generations_test.go b/services/agents-api/internal/store/runtime_node_generations_test.go index 378c580b6..e48c4857f 100644 --- a/services/agents-api/internal/store/runtime_node_generations_test.go +++ b/services/agents-api/internal/store/runtime_node_generations_test.go @@ -3,11 +3,12 @@ package store import ( "database/sql" "errors" - "github.com/jackc/pgx/v5/stdlib" - "github.com/pressly/goose/v3" "os" "testing" + "github.com/jackc/pgx/v5/stdlib" + "github.com/pressly/goose/v3" + "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/device" "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/sandbox" "github.com/google/uuid" diff --git a/services/agents-api/internal/store/runtime_node_types.go b/services/agents-api/internal/store/runtime_node_types.go index 6830dc5f8..13f178286 100644 --- a/services/agents-api/internal/store/runtime_node_types.go +++ b/services/agents-api/internal/store/runtime_node_types.go @@ -1,9 +1,11 @@ package store import ( + "encoding/json" "errors" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/sandbox" "time" + + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/sandbox" ) var ( @@ -95,34 +97,6 @@ type SandboxDeploymentResources struct { Pending int64 `json:"pending"` } -// SandboxE2BView is the safe E2B selection. It never includes the API key. -type SandboxE2BView struct { - Template string `json:"template"` - APIURL string `json:"api_url"` - Domain string `json:"domain"` - CredentialConfigured bool `json:"credential_configured"` - // The fixed template build as Core read it when this selection was saved. - TemplateBuild SandboxE2BTemplateBuildView `json:"template_build"` -} - -// SandboxE2BTemplateBuildView is the fixed template build as Core read it when -// this selection was saved; GET does not call E2B. Unknown values are null, -// including every value of a selection saved before Core recorded them. -type SandboxE2BTemplateBuildView struct { - // Build status at selection time; validation admits only ready builds. - Status *string `json:"status" extensions:"x-nullable"` - Resources SandboxTemplateResources `json:"resources"` -} - -// SandboxTemplateResources uses the specification.resources names. Validation -// requires cpus and memory_mib to equal the selected limits; root_disk_mib is -// the build's native disk size, which Core does not enforce separately. -type SandboxTemplateResources struct { - CPUs *int32 `json:"cpus" extensions:"x-nullable"` - MemoryMiB *int32 `json:"memory_mib" extensions:"x-nullable"` - RootDiskMiB *int32 `json:"root_disk_mib" extensions:"x-nullable"` -} - // SandboxSuspensionView is the idle suspension policy. Only microsandbox // suspends sandboxes; Docker and E2B deployments return null. type SandboxSuspensionView struct { @@ -149,13 +123,15 @@ type SandboxRollout struct { Nodes *SandboxRolloutNodes `json:"nodes" extensions:"x-nullable"` } type RuntimeDeploymentView struct { - Rollout SandboxRollout `json:"rollout"` - Specification *sandbox.DeploymentSpec `json:"specification,omitempty"` - SpecificationDigest string `json:"specification_digest,omitempty"` - Generation uint64 `json:"generation"` - Mode string `json:"mode"` - Resources SandboxDeploymentResources `json:"resources"` - E2B *SandboxE2BView `json:"e2b,omitempty"` + Rollout SandboxRollout `json:"rollout"` + Specification *sandbox.DeploymentSpec `json:"specification,omitempty"` + SpecificationDigest string `json:"specification_digest,omitempty"` + Generation uint64 `json:"generation"` + Mode string `json:"mode"` + Resources SandboxDeploymentResources `json:"resources"` + Configuration json.RawMessage `json:"configuration,omitempty" swaggertype:"object"` + Metadata json.RawMessage `json:"metadata,omitempty" swaggertype:"object"` + CredentialConfigured bool `json:"credential_configured"` // Idle suspension policy; microsandbox only, otherwise null. Suspension *SandboxSuspensionView `json:"suspension" extensions:"x-nullable"` InstallationID string `json:"installation_id"` diff --git a/services/agents-api/internal/store/runtime_nodes.go b/services/agents-api/internal/store/runtime_nodes.go index f3af3dd8b..995c93825 100644 --- a/services/agents-api/internal/store/runtime_nodes.go +++ b/services/agents-api/internal/store/runtime_nodes.go @@ -7,11 +7,12 @@ import ( "encoding/hex" "encoding/json" "errors" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/sandbox" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/sandbox/providers" "strings" "time" + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/sandbox" + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/sandbox/providers" + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/db/sqlc" "github.com/google/uuid" "github.com/jackc/pgx/v5" @@ -76,7 +77,10 @@ func (s *Store) deploymentView(ctx context.Context, q *sqlc.Queries) (RuntimeDep return RuntimeDeploymentView{}, err } d := row.RuntimeDeployment - result := runtimeDeploymentView(d, s.publicURL) + result, err := runtimeDeploymentView(d, s.publicURL) + if err != nil { + return RuntimeDeploymentView{}, err + } if err := json.Unmarshal(row.Rollout, &result.Rollout); err != nil { return RuntimeDeploymentView{}, err } diff --git a/services/agents-api/internal/store/runtime_placements.go b/services/agents-api/internal/store/runtime_placements.go index 5dddee480..b7ab7e68d 100644 --- a/services/agents-api/internal/store/runtime_placements.go +++ b/services/agents-api/internal/store/runtime_placements.go @@ -2,7 +2,9 @@ package store import ( "context" + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/sandbox" + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/sandbox/providers" "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/db/sqlc" "github.com/jackc/pgx/v5/pgtype" @@ -26,7 +28,7 @@ func reserveRuntimePlacement(ctx context.Context, q *sqlc.Queries, session pgtyp // E2B guests reach Core over the internet. A selection saved before the // public URL became loopback admits nothing, while its existing sandboxes // stay reachable for cleanup through the loaded provider. - if LoopbackOrigin(publicURL) { + if providers.RequiresPublicOrigin(d.ProviderKind) && LoopbackOrigin(publicURL) { return ErrSandboxPublicURLUnreachable } return nil diff --git a/services/agents-api/internal/store/sandbox_deployment_mutations.go b/services/agents-api/internal/store/sandbox_deployment_mutations.go index df0de2d55..c80989592 100644 --- a/services/agents-api/internal/store/sandbox_deployment_mutations.go +++ b/services/agents-api/internal/store/sandbox_deployment_mutations.go @@ -7,10 +7,8 @@ import ( "math" "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/db/sqlc" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/sandbox" "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/sandbox/providers" "github.com/jackc/pgx/v5" - "github.com/jackc/pgx/v5/pgtype" ) var ErrSandboxCredentialUnavailable = errors.New("sandbox credential encryption is unavailable") @@ -29,38 +27,30 @@ func validateSandboxSelection(input SandboxDeploymentSetupRequest) error { } func (s *Store) sandboxSelectionEqual(d sqlc.RuntimeDeployment, input SandboxDeploymentSetupRequest) (bool, error) { - var spec sandbox.DeploymentSpec - if json.Unmarshal(d.Specification, &spec) != nil { - return false, ErrSandboxDeploymentConflict - } - if spec.Digest(d.ProviderKind) != input.DeploymentSpec.Digest(input.Provider) { - return false, nil - } if d.ProviderKind != input.Provider { return false, nil } - if input.E2B == nil { - return d.E2bTemplate == "", nil + previous, err := s.sandboxSetup(d) + if err != nil { + return false, err } - if d.E2bTemplate != input.E2B.Template { - return false, nil + normalized, err := providers.Normalize(input) + if err != nil { + return false, sandboxConfigurationError(err) } - saved := input - saved.E2B = &sandbox.E2BConfiguration{APIKey: input.E2B.APIKey, Template: d.E2bTemplate, APIURL: d.E2bApiUrl, Domain: d.E2bDomain} - saved, savedErr := providers.Normalize(saved) - normalized, inputErr := providers.Normalize(input) - if savedErr != nil || inputErr != nil || saved.E2B.APIURL != normalized.E2B.APIURL || saved.E2B.Domain != normalized.E2B.Domain { + if previous.Specification.Digest(d.ProviderKind) != normalized.DeploymentSpec.Digest(input.Provider) { return false, nil } - key, err := s.credentialCipher.OpenSandboxDeployment(d.E2bCredential, runtimeUUID(d.InstallationID), uint64(d.Generation)) - if err != nil { - return false, ErrSandboxCredentialUnavailable - } - return string(key) == input.E2B.APIKey, nil + return providers.Equal(input.Provider, previous.Configuration, normalized.Configuration) } +func configurationJSON(raw json.RawMessage) json.RawMessage { + if len(raw) == 0 { + return json.RawMessage(`{}`) + } + return raw +} func (s *Store) saveSandboxSelection(ctx context.Context, q *sqlc.Queries, d sqlc.RuntimeDeployment, input SandboxDeploymentSetupRequest) error { - // Only a complete specification is stored, including derived E2B resources. if err := providers.ValidateSpecification(input.Provider, input.DeploymentSpec); err != nil { return sandboxConfigurationError(err) } @@ -72,48 +62,34 @@ func (s *Store) saveSandboxSelection(ctx context.Context, q *sqlc.Queries, d sql if err != nil { return sandboxConfigurationError(err) } - normalized, err := providers.Normalize(input) + input, err = providers.Normalize(input) + if err != nil { + return sandboxConfigurationError(err) + } + record, err := providers.Encode(input.Provider, input.Configuration) if err != nil { return sandboxConfigurationError(err) } - input = normalized - params := sqlc.InitializeSandboxDeploymentParams{ProviderKind: input.Provider, BackendFingerprint: description.BackendFingerprint, Generation: generation, Mode: description.Mode, IdleSeconds: description.IdleSeconds, RetentionSeconds: description.RetentionSeconds} + params := sqlc.InitializeSandboxDeploymentParams{ProviderKind: input.Provider, BackendFingerprint: description.BackendFingerprint, Generation: generation, Mode: description.Mode, IdleSeconds: description.IdleSeconds, RetentionSeconds: description.RetentionSeconds, ProviderConfig: configurationJSON(record.Public), ProviderMetadata: configurationJSON(record.Metadata)} params.Specification, _ = json.Marshal(input.DeploymentSpec) - if input.E2B != nil { - encrypted, err := s.credentialCipher.SealSandboxDeployment([]byte(input.E2B.APIKey), runtimeUUID(d.InstallationID), uint64(generation)) + if len(record.Secret) > 0 { + params.ProviderCredential, err = s.credentialCipher.SealSandboxDeployment(record.Secret, runtimeUUID(d.InstallationID), uint64(generation)) if err != nil { return ErrSandboxCredentialUnavailable } - params.E2bCredential, params.E2bTemplate = encrypted, input.E2B.Template - params.E2bApiUrl, params.E2bDomain = input.E2B.APIURL, input.E2B.Domain - build := templateBuildColumns(input.E2B.TemplateBuild) - params.E2bTemplateBuildStatus, params.E2bTemplateCpus = build.E2bTemplateBuildStatus, build.E2bTemplateCpus - params.E2bTemplateMemoryMib, params.E2bTemplateRootDiskMib = build.E2bTemplateMemoryMib, build.E2bTemplateRootDiskMib } return q.InitializeSandboxDeployment(ctx, params) } -func templateBuildColumns(build *sandbox.TemplateBuild) sqlc.RecordSandboxTemplateBuildParams { - var params sqlc.RecordSandboxTemplateBuildParams - if build != nil { - params.E2bTemplateBuildStatus = pgtype.Text{String: build.Status, Valid: true} - params.E2bTemplateCpus = pgtype.Int4{Int32: build.CPUs, Valid: true} - params.E2bTemplateMemoryMib = pgtype.Int4{Int32: build.MemoryMiB, Valid: true} - if build.RootDiskMiB != nil { - params.E2bTemplateRootDiskMib = pgtype.Int4{Int32: *build.RootDiskMiB, Valid: true} - } +func recordConfigurationMetadata(ctx context.Context, q *sqlc.Queries, input SandboxDeploymentSetupRequest) error { + record, err := providers.Encode(input.Provider, input.Configuration) + if err != nil { + return sandboxConfigurationError(err) } - return params -} - -// recordTemplateBuild saves the build read by this request's validation when -// the selection is otherwise unchanged, without a new generation. Saving the -// same E2B selection again thus records a build that an older Core did not. -func recordTemplateBuild(ctx context.Context, q *sqlc.Queries, input SandboxDeploymentSetupRequest) error { - if input.E2B == nil || input.E2B.TemplateBuild == nil { + if len(record.Metadata) == 0 { return nil } - return q.RecordSandboxTemplateBuild(ctx, templateBuildColumns(input.E2B.TemplateBuild)) + return q.RecordSandboxConfigurationMetadata(ctx, record.Metadata) } func (s *Store) InitializeSandboxDeployment(ctx context.Context, installationID string, input SandboxDeploymentSetupRequest) (RuntimeDeploymentView, error) { @@ -153,7 +129,7 @@ func (s *Store) InitializeSandboxDeployment(ctx context.Context, installationID if !equal { return ErrSandboxDeploymentConflict } - if err := recordTemplateBuild(ctx, q, input); err != nil { + if err := recordConfigurationMetadata(ctx, q, input); err != nil { return err } } else if err := s.saveSandboxSelection(ctx, q, d, input); err != nil { @@ -228,7 +204,7 @@ func (s *Store) UpdateSandboxDeployment(ctx context.Context, installation string if err != nil { return err } - if !equal || input.E2B != nil && input.E2B.ReplaceCredential { + if !equal || input.ReplacesCredential() { if err := q.RetainSandboxGeneration(ctx); err != nil { return err } @@ -240,14 +216,14 @@ func (s *Store) UpdateSandboxDeployment(ctx context.Context, installation string } { action := "change" - if input.E2B != nil && input.E2B.ReplaceCredential { + if input.ReplacesCredential() { action = "replace_credential" } if err := recordDeploymentMutation(ctx, q, action, "sandbox_deployment", installation); err != nil { return err } } - } else if err := recordTemplateBuild(ctx, q, input.SandboxDeploymentSetupRequest); err != nil { + } else if err := recordConfigurationMetadata(ctx, q, input.SandboxDeploymentSetupRequest); err != nil { return err } result, err = s.deploymentView(ctx, q) diff --git a/services/agents-api/internal/store/sandbox_deployment_setup.go b/services/agents-api/internal/store/sandbox_deployment_setup.go index 40796a341..170b54d91 100644 --- a/services/agents-api/internal/store/sandbox_deployment_setup.go +++ b/services/agents-api/internal/store/sandbox_deployment_setup.go @@ -28,7 +28,7 @@ type SandboxSetup struct { Generation uint64 Mode string AdmissionPaused bool - E2B *sandbox.E2BConfiguration + Configuration sandbox.Configuration `json:"-"` IdleSeconds, RetentionSeconds int64 } @@ -55,17 +55,22 @@ func (s *Store) sandboxSetup(d sqlc.RuntimeDeployment) (SandboxSetup, error) { return SandboxSetup{}, err } } - if providers.UsesCredential(d.ProviderKind) { - credential, err := s.credentialCipher.OpenSandboxDeployment(d.E2bCredential, result.InstallationID, result.Generation) - if err != nil { - return SandboxSetup{}, ErrSandboxCredentialUnavailable + if d.ProviderKind != "" { + var secret []byte + if len(d.ProviderCredential) > 0 { + var err error + secret, err = s.credentialCipher.OpenSandboxDeployment(d.ProviderCredential, result.InstallationID, result.Generation) + if err != nil { + return SandboxSetup{}, ErrSandboxCredentialUnavailable + } } - selection, err := providers.Restore(sandbox.Selection{Provider: d.ProviderKind, DeploymentSpec: result.Specification, E2B: &sandbox.E2BConfiguration{APIKey: string(credential), Template: d.E2bTemplate, APIURL: d.E2bApiUrl, Domain: d.E2bDomain}}) + var err error + result.Configuration, err = providers.Decode(d.ProviderKind, sandbox.ConfigurationRecord{Public: d.ProviderConfig, Metadata: d.ProviderMetadata, Secret: secret}) if err != nil { return SandboxSetup{}, ErrSandboxDeploymentConflict } - result.E2B = selection.E2B } + return result, nil } @@ -160,7 +165,7 @@ func LoopbackOrigin(value string) bool { return u.Hostname() == "localhost" } -func runtimeDeploymentView(d sqlc.RuntimeDeployment, publicURL string) RuntimeDeploymentView { +func runtimeDeploymentView(d sqlc.RuntimeDeployment, publicURL string) (RuntimeDeploymentView, error) { result := RuntimeDeploymentView{InstallationID: runtimeUUID(d.InstallationID), Provider: d.ProviderKind, CoreURL: publicURL, OwnerEpoch: uint64(d.OwnerEpoch), Generation: uint64(d.Generation), Mode: d.Mode} if len(d.Specification) > 0 && string(d.Specification) != "{}" { var spec sandbox.DeploymentSpec @@ -169,21 +174,24 @@ func runtimeDeploymentView(d sqlc.RuntimeDeployment, publicURL string) RuntimeDe result.SpecificationDigest = spec.Digest(d.ProviderKind) } } - if providers.UsesCredential(d.ProviderKind) { - selection, _ := providers.Restore(sandbox.Selection{Provider: d.ProviderKind, E2B: &sandbox.E2BConfiguration{Template: d.E2bTemplate, APIURL: d.E2bApiUrl, Domain: d.E2bDomain}}) - apiURL, domain := selection.E2B.APIURL, selection.E2B.Domain - result.E2B = &SandboxE2BView{Template: d.E2bTemplate, APIURL: apiURL, Domain: domain, CredentialConfigured: len(d.E2bCredential) > 0, - TemplateBuild: SandboxE2BTemplateBuildView{Resources: SandboxTemplateResources{ - CPUs: optionalInt32(d.E2bTemplateCpus), MemoryMiB: optionalInt32(d.E2bTemplateMemoryMib), RootDiskMiB: optionalInt32(d.E2bTemplateRootDiskMib)}}} - if d.E2bTemplateBuildStatus.Valid { - status := d.E2bTemplateBuildStatus.String - result.E2B.TemplateBuild.Status = &status + if d.ProviderKind != "" { + value, err := providers.Decode(d.ProviderKind, sandbox.ConfigurationRecord{Public: d.ProviderConfig, Metadata: d.ProviderMetadata}) + if err != nil { + return RuntimeDeploymentView{}, ErrSandboxDeploymentConflict + } + record, err := providers.Encode(d.ProviderKind, value) + if err != nil { + return RuntimeDeploymentView{}, ErrSandboxDeploymentConflict } + result.Configuration = configurationJSON(record.Public) + result.Metadata = configurationJSON(record.Metadata) + result.CredentialConfigured = len(d.ProviderCredential) > 0 } + if providers.SupportsCheckpoint(d.ProviderKind) { result.Suspension = &SandboxSuspensionView{IdleSeconds: d.IdleSeconds, RetentionSeconds: d.RetentionSeconds} } - return result + return result, nil } func optionalInt32(value pgtype.Int4) *int32 { diff --git a/services/agents-api/internal/store/sandbox_deployment_setup_test.go b/services/agents-api/internal/store/sandbox_deployment_setup_test.go index 5d8c3ede8..5db61b371 100644 --- a/services/agents-api/internal/store/sandbox_deployment_setup_test.go +++ b/services/agents-api/internal/store/sandbox_deployment_setup_test.go @@ -8,6 +8,8 @@ import ( "sync" "testing" + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/sandbox/e2b" + "github.com/google/uuid" ) @@ -155,7 +157,7 @@ func TestSandboxSelectionRejectsWhitespaceInE2BCredential(t *testing.T) { for _, separator := range []string{" ", "\t", "\r", "\n", "\x00", "\u00a0", "\u2003", "\u3000"} { t.Run(fmt.Sprintf("U+%04X", []rune(separator)[0]), func(t *testing.T) { selection := e2bSelection() - selection.E2B.APIKey = "prefix" + separator + "suffix" + selection.Configuration.(*e2b.DeploymentConfiguration).APIKey = "prefix" + separator + "suffix" if err := validateSandboxSelection(selection); !errors.Is(err, ErrInvalidInput) { t.Fatalf("credential containing whitespace or NUL accepted: %v", err) } diff --git a/services/agents-api/internal/store/sandbox_deployment_switch_test.go b/services/agents-api/internal/store/sandbox_deployment_switch_test.go index c6785282a..7c2fb3b22 100644 --- a/services/agents-api/internal/store/sandbox_deployment_switch_test.go +++ b/services/agents-api/internal/store/sandbox_deployment_switch_test.go @@ -8,14 +8,15 @@ import ( "sync" "testing" + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/sandbox/e2b" + "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/device" "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/credentialcrypto" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/sandbox" "github.com/google/uuid" ) func e2bSelection() SandboxDeploymentSetupRequest { - return SandboxDeploymentSetupRequest{DeploymentSpec: SandboxDeploymentTestSpec("e2b"), Provider: "e2b", E2B: &sandbox.E2BConfiguration{APIKey: "fixture-private-api-key", Template: "runtime:" + uuid.NewString()}} + return SandboxDeploymentSetupRequest{DeploymentSpec: SandboxDeploymentTestSpec("e2b"), Provider: "e2b", Configuration: &e2b.DeploymentConfiguration{APIKey: "fixture-private-api-key", Template: "runtime:" + uuid.NewString()}} } func TestSandboxE2BEndpointPersistenceAndOnlineSwitch(t *testing.T) { @@ -31,20 +32,20 @@ func TestSandboxE2BEndpointPersistenceAndOnlineSwitch(t *testing.T) { t.Fatal(err) } input := e2bSelection() - input.E2B.APIURL, input.E2B.Domain = "https://sandbox-test.sandbase.ai", "sandbox-test.sandbase.ai" + input.Configuration.(*e2b.DeploymentConfiguration).APIURL, input.Configuration.(*e2b.DeploymentConfiguration).Domain = "https://sandbox-test.sandbase.ai", "sandbox-test.sandbase.ai" view, err := w.InitializeSandboxDeployment(t.Context(), id, input) - if err != nil || view.E2B == nil || view.E2B.APIURL != input.E2B.APIURL || view.E2B.Domain != input.E2B.Domain { + if err != nil || view.Configuration == nil || e2bPublicConfiguration(t, view).APIURL != input.Configuration.(*e2b.DeploymentConfiguration).APIURL || e2bPublicConfiguration(t, view).Domain != input.Configuration.(*e2b.DeploymentConfiguration).Domain { t.Fatal("custom endpoint was not returned", view, err) } setup, err := s.GetSandboxSetup(t.Context()) - if err != nil || setup.E2B == nil || setup.E2B.APIURL != input.E2B.APIURL || setup.E2B.Domain != input.E2B.Domain { + if err != nil || setup.Configuration == nil || setup.Configuration.(*e2b.DeploymentConfiguration).APIURL != input.Configuration.(*e2b.DeploymentConfiguration).APIURL || setup.Configuration.(*e2b.DeploymentConfiguration).Domain != input.Configuration.(*e2b.DeploymentConfiguration).Domain { t.Fatal("custom endpoint was not persisted", setup, err) } change := e2bSelection() - change.E2B.Template = input.E2B.Template + change.Configuration.(*e2b.DeploymentConfiguration).Template = input.Configuration.(*e2b.DeploymentConfiguration).Template update := SandboxDeploymentUpdateRequest{SandboxDeploymentSetupRequest: change, ExpectedGeneration: view.Generation} changed, err := w.UpdateSandboxDeployment(SandboxResetTestContext(t.Context()), id, update) - if err != nil || changed.Generation != view.Generation+1 || changed.E2B == nil || changed.E2B.APIURL != "https://api.e2b.app" || changed.E2B.Domain != "e2b.app" { + if err != nil || changed.Generation != view.Generation+1 || changed.Configuration == nil || e2bPublicConfiguration(t, changed).APIURL != "https://api.e2b.app" || e2bPublicConfiguration(t, changed).Domain != "e2b.app" { t.Fatal("online endpoint switch failed", changed, err) } } @@ -62,7 +63,7 @@ func TestSandboxResetClearsCustomE2BEndpoint(t *testing.T) { t.Fatal(err) } input := e2bSelection() - input.E2B.APIURL, input.E2B.Domain = "https://sandbox-test.sandbase.ai", "sandbox-test.sandbase.ai" + input.Configuration.(*e2b.DeploymentConfiguration).APIURL, input.Configuration.(*e2b.DeploymentConfiguration).Domain = "https://sandbox-test.sandbase.ai", "sandbox-test.sandbase.ai" configured, err := w.InitializeSandboxDeployment(t.Context(), installation, input) if err != nil { t.Fatal(err) @@ -77,7 +78,7 @@ func TestSandboxResetClearsCustomE2BEndpoint(t *testing.T) { t.Fatal("custom endpoint blocked reset completion", empty, err) } var apiURL, domain string - if err := pool.QueryRow(t.Context(), "SELECT e2b_api_url, e2b_domain FROM runtime_deployment").Scan(&apiURL, &domain); err != nil || apiURL != "" || domain != "" { + if err := pool.QueryRow(t.Context(), "SELECT COALESCE(provider_config->>'api_url',''), COALESCE(provider_config->>'domain','') FROM runtime_deployment").Scan(&apiURL, &domain); err != nil || apiURL != "" || domain != "" { t.Fatal("reset retained custom endpoint", apiURL, domain, err) } } @@ -96,19 +97,19 @@ func TestSandboxDirectDeploymentOwnershipAndCleanSwitch(t *testing.T) { } input := e2bSelection() view, err := w.InitializeSandboxDeployment(t.Context(), id, input) - if err != nil || view.Generation != 1 || view.Mode != "direct" || view.E2B == nil || !view.E2B.CredentialConfigured { + if err != nil || view.Generation != 1 || view.Mode != "direct" || view.Configuration == nil || !view.CredentialConfigured { t.Fatal("direct setup", view, err) } raw, _ := json.Marshal(view) - if bytes.Contains(raw, []byte(input.E2B.APIKey)) { + if bytes.Contains(raw, []byte(input.Configuration.(*e2b.DeploymentConfiguration).APIKey)) { t.Fatal("credential in public view") } var ciphertext []byte - if err := pool.QueryRow(t.Context(), "SELECT e2b_credential FROM runtime_deployment").Scan(&ciphertext); err != nil || bytes.Contains(ciphertext, []byte(input.E2B.APIKey)) { + if err := pool.QueryRow(t.Context(), "SELECT provider_credential FROM runtime_deployment").Scan(&ciphertext); err != nil || bytes.Contains(ciphertext, []byte(input.Configuration.(*e2b.DeploymentConfiguration).APIKey)) { t.Fatal("credential not encrypted", err) } setup, err := s.GetSandboxSetup(t.Context()) - if err != nil || setup.E2B.APIKey != input.E2B.APIKey { + if err != nil || setup.Configuration.(*e2b.DeploymentConfiguration).APIKey != input.Configuration.(*e2b.DeploymentConfiguration).APIKey { t.Fatal("internal credential unavailable", err) } if _, err := s.CreateRuntimeEnrollment(t.Context(), RuntimeNodeCapacity{MaxActive: 2, MaxRetained: 8}); !errors.Is(err, ErrSandboxDeploymentConflict) { @@ -159,7 +160,7 @@ func TestSandboxDirectDeploymentOwnershipAndCleanSwitch(t *testing.T) { t.Fatal(err) } changed, err := resetAndSelect(t, w, id, update.ExpectedGeneration, update.SandboxDeploymentSetupRequest) - if err != nil || changed.Generation != 3 || changed.Mode != "nodes" || changed.Reset != nil || changed.E2B != nil || changed.Resources != (SandboxDeploymentResources{}) { + if err != nil || changed.Generation != 3 || changed.Mode != "nodes" || changed.Reset != nil || string(changed.Configuration) != "{}" || changed.Resources != (SandboxDeploymentResources{}) { t.Fatal("clean switch", changed, err) } if _, err := w.CancelSandboxReset(SandboxResetTestContext(t.Context()), id, 1); !errors.Is(err, ErrSandboxDeploymentConflict) { diff --git a/services/agents-api/internal/store/sandbox_deployment_switch_worker_test.go b/services/agents-api/internal/store/sandbox_deployment_switch_worker_test.go index 94b580d43..2ac24224f 100644 --- a/services/agents-api/internal/store/sandbox_deployment_switch_worker_test.go +++ b/services/agents-api/internal/store/sandbox_deployment_switch_worker_test.go @@ -10,6 +10,8 @@ import ( "testing" "time" + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/sandbox/e2b" + "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/gateway" "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/credentialcrypto" "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/execution" @@ -141,7 +143,7 @@ func TestSandboxWorkerSwitchesAndRecoversFailedActivation(t *testing.T) { return store.RuntimeDeploymentView{} } empty = reset(2) - cloud := store.SandboxDeploymentSetupRequest{ExpectedGeneration: empty.Generation, DeploymentSpec: store.SandboxDeploymentTestSpec("e2b"), Provider: "e2b", E2B: &sandbox.E2BConfiguration{APIKey: "fixture-api-key", Template: "runtime:" + uuid.NewString()}} + cloud := store.SandboxDeploymentSetupRequest{ExpectedGeneration: empty.Generation, DeploymentSpec: store.SandboxDeploymentTestSpec("e2b"), Provider: "e2b", Configuration: &e2b.DeploymentConfiguration{APIKey: "fixture-api-key", Template: "runtime:" + uuid.NewString()}} if _, err := w.InitializeSandboxDeployment(t.Context(), cloud); err != nil { t.Fatal("setup after unconfigured publication", err) } diff --git a/services/agents-api/internal/store/sandbox_deployment_view_test.go b/services/agents-api/internal/store/sandbox_deployment_view_test.go index b350bda1b..8c30bd9ff 100644 --- a/services/agents-api/internal/store/sandbox_deployment_view_test.go +++ b/services/agents-api/internal/store/sandbox_deployment_view_test.go @@ -6,6 +6,8 @@ import ( "errors" "testing" + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/sandbox/e2b" + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/credentialcrypto" "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/sandbox" "github.com/google/uuid" @@ -31,7 +33,7 @@ func TestSandboxDeploymentViewRecordsTemplateBuildAndSuspension(t *testing.T) { } disk := int32(24063) input.Resources = sandbox.Resources{CPUs: 2, MemoryMiB: 2048} - input.E2B.TemplateBuild = &sandbox.TemplateBuild{Status: "ready", CPUs: 2, MemoryMiB: 2048, RootDiskMiB: &disk} + input.Configuration.(*e2b.DeploymentConfiguration).TemplateBuild = &e2b.DeploymentBuild{Status: "ready", CPUs: 2, MemoryMiB: 2048, RootDiskMiB: &disk} view, err := w.InitializeSandboxDeployment(t.Context(), id, input) if err != nil { t.Fatal(err) @@ -50,7 +52,7 @@ func TestSandboxDeploymentViewRecordsTemplateBuildAndSuspension(t *testing.T) { // saving the identical selection again records it without a new generation. forget := func() { t.Helper() - if _, err := pool.Exec(t.Context(), "UPDATE runtime_deployment SET e2b_template_build_status=NULL, e2b_template_cpus=NULL, e2b_template_memory_mib=NULL, e2b_template_root_disk_mib=NULL"); err != nil { + if _, err := pool.Exec(t.Context(), "UPDATE runtime_deployment SET provider_metadata='{}'::jsonb"); err != nil { t.Fatal(err) } } @@ -58,7 +60,7 @@ func TestSandboxDeploymentViewRecordsTemplateBuildAndSuspension(t *testing.T) { forget() view, err = s.GetRuntimeDeployment(t.Context()) raw, _ = json.Marshal(view) - if err != nil || !bytes.Contains(raw, []byte(`"template_build":{"status":null,"resources":{"cpus":null,"memory_mib":null,"root_disk_mib":null}}`)) { + if err != nil || !bytes.Contains(raw, []byte(`"metadata":{}`)) { t.Fatalf("unknown build was not null: %s %v", raw, err) } input.ExpectedGeneration = 1 @@ -76,7 +78,16 @@ func TestSandboxDeploymentViewRecordsTemplateBuildAndSuspension(t *testing.T) { update := SandboxDeploymentUpdateRequest{SandboxDeploymentSetupRequest: SandboxDeploymentSetupRequest{ DeploymentSpec: SandboxDeploymentTestSpec("microsandbox"), Provider: "microsandbox"}, ExpectedGeneration: 1} view, err = resetAndSelect(t, w, id, update.ExpectedGeneration, update.SandboxDeploymentSetupRequest) - if err != nil || view.E2B != nil || view.Suspension == nil || view.Suspension.IdleSeconds != 300 || view.Suspension.RetentionSeconds != 86400 { + if err != nil || string(view.Configuration) != "{}" || view.Suspension == nil || view.Suspension.IdleSeconds != 300 || view.Suspension.RetentionSeconds != 86400 { t.Fatalf("microsandbox suspension view = %+v %v", view, err) } } + +func e2bPublicConfiguration(t *testing.T, v RuntimeDeploymentView) *e2b.DeploymentConfiguration { + t.Helper() + c, err := (e2b.ConfigurationAdapter{}).Decode(sandbox.ConfigurationRecord{Public: v.Configuration, Metadata: v.Metadata}) + if err != nil { + t.Fatal(err) + } + return c.(*e2b.DeploymentConfiguration) +} diff --git a/services/agents-api/internal/store/sandbox_generations.go b/services/agents-api/internal/store/sandbox_generations.go index f66db7ff1..cbb8f3ba2 100644 --- a/services/agents-api/internal/store/sandbox_generations.go +++ b/services/agents-api/internal/store/sandbox_generations.go @@ -24,7 +24,7 @@ func (s *Store) ClassifySandboxDeploymentChange(ctx context.Context, installatio if err != nil { return err } - resolved, err := providers.ResolveChange(input.SandboxDeploymentSetupRequest, sandbox.Selection{Provider: previous.Provider, DeploymentSpec: previous.Specification, E2B: previous.E2B}) + resolved, err := providers.ResolveChange(input.SandboxDeploymentSetupRequest, sandbox.Selection{Provider: previous.Provider, DeploymentSpec: previous.Specification, Configuration: previous.Configuration}) if err != nil { return sandboxConfigurationError(err) } @@ -33,7 +33,7 @@ func (s *Store) ClassifySandboxDeploymentChange(ctx context.Context, installatio return err } equal, err := s.sandboxSelectionEqual(d, input.SandboxDeploymentSetupRequest) - unchanged = equal && (input.E2B == nil || !input.E2B.ReplaceCredential) + unchanged = equal && !input.ReplacesCredential() return err }) return input, unchanged, err @@ -86,10 +86,19 @@ func (s *Store) GetSandboxAllocationSetup(ctx context.Context, ref sandbox.Refer if err = json.Unmarshal(g.Specification, &result.Specification); err != nil { return SandboxSetup{}, err } - if result.E2B != nil { - result.E2B.Template = g.E2bTemplate - result.E2B.APIURL, result.E2B.Domain = g.E2bApiUrl, g.E2bDomain + retained, err := providers.Decode(g.ProviderKind, sandbox.ConfigurationRecord{Public: g.ProviderConfig, Metadata: g.ProviderMetadata}) + if err != nil { + return SandboxSetup{}, ErrSandboxDeploymentConflict + } + if providers.UsesCredential(g.ProviderKind) { + composed, err := providers.WithCredential(sandbox.Selection{Provider: g.ProviderKind, Configuration: retained}, sandbox.Selection{Provider: d.ProviderKind, Configuration: result.Configuration}) + if err != nil { + return SandboxSetup{}, ErrSandboxDeploymentConflict + } + retained = composed.Configuration } + result.Configuration = retained + } return result, tx.Commit(ctx) } @@ -106,9 +115,11 @@ func (s *Store) SandboxGenerationPage(ctx context.Context, after int64) ([]Sandb if err := json.Unmarshal(r.Specification, &v.Specification); err != nil { return nil, err } - if providers.UsesCredential(r.ProviderKind) { - v.E2B = &sandbox.E2BConfiguration{Template: r.E2bTemplate, APIURL: r.E2bApiUrl, Domain: r.E2bDomain} + v.Configuration, err = providers.Decode(r.ProviderKind, sandbox.ConfigurationRecord{Public: r.ProviderConfig, Metadata: r.ProviderMetadata}) + if err != nil { + return nil, ErrSandboxDeploymentConflict } + result = append(result, v) } return result, nil diff --git a/services/agents-api/internal/store/sandbox_generations_test.go b/services/agents-api/internal/store/sandbox_generations_test.go index 09d749eed..e637d2135 100644 --- a/services/agents-api/internal/store/sandbox_generations_test.go +++ b/services/agents-api/internal/store/sandbox_generations_test.go @@ -3,12 +3,15 @@ package store import ( "database/sql" "errors" + "os" + "testing" + + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/sandbox/e2b" + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/db/sqlc" "github.com/jackc/pgx/v5/pgtype" "github.com/jackc/pgx/v5/stdlib" "github.com/pressly/goose/v3" - "os" - "testing" "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/sandbox" "github.com/google/uuid" @@ -19,9 +22,9 @@ func TestE2BGenerationsRetainOwnershipAndUseCurrentCredential(t *testing.T) { tenant, session := managedArchiveSession(t, s, managerSessionInput(uuid.NewString())) owner := archiveAllocation(t, w, tenant, session, view.InstallationID) ctx := SandboxResetTestContext(t.Context()) - oldTemplate := input.E2B.Template - input.E2B.Template = "next:" + uuid.NewString() - input.E2B.APIURL, input.E2B.Domain = "https://sandbox.example.com", "sandbox.example.com" + oldTemplate := input.Configuration.(*e2b.DeploymentConfiguration).Template + input.Configuration.(*e2b.DeploymentConfiguration).Template = "next:" + uuid.NewString() + input.Configuration.(*e2b.DeploymentConfiguration).APIURL, input.Configuration.(*e2b.DeploymentConfiguration).Domain = "https://sandbox.example.com", "sandbox.example.com" input.Resources.CPUs++ changed, err := w.UpdateSandboxDeployment(ctx, view.InstallationID, SandboxDeploymentUpdateRequest{SandboxDeploymentSetupRequest: input, ExpectedGeneration: 1}) if err != nil || changed.Generation != 2 || changed.OwnerEpoch != view.OwnerEpoch || changed.Rollout.PreviousGenerationSandboxes != 1 || changed.Rollout.State != "settled" { @@ -30,17 +33,17 @@ func TestE2BGenerationsRetainOwnershipAndUseCurrentCredential(t *testing.T) { assertSandboxSnapshotEquivalent(t, s.pool) ref := sandbox.Reference{TenantID: tenant, EnvironmentID: owner.EnvironmentID, AllocationID: owner.ID} retained, err := s.GetSandboxAllocationSetup(t.Context(), ref) - if err != nil || retained.Generation != 1 || retained.E2B.Template != oldTemplate || retained.E2B.APIURL != "https://api.e2b.app" || retained.Specification.Resources.CPUs == input.Resources.CPUs { + if err != nil || retained.Generation != 1 || retained.Configuration.(*e2b.DeploymentConfiguration).Template != oldTemplate || retained.Configuration.(*e2b.DeploymentConfiguration).APIURL != "https://api.e2b.app" || retained.Specification.Resources.CPUs == input.Resources.CPUs { t.Fatal(retained, err) } - input.E2B.APIKey = "replacement-secret" - input.E2B.ReplaceCredential = true + input.Configuration.(*e2b.DeploymentConfiguration).APIKey = "replacement-secret" + input.Configuration.(*e2b.DeploymentConfiguration).CredentialSupplied = true changed, err = w.UpdateSandboxDeployment(ctx, view.InstallationID, SandboxDeploymentUpdateRequest{SandboxDeploymentSetupRequest: input, ExpectedGeneration: 2}) if err != nil || changed.Generation != 3 || changed.OwnerEpoch != view.OwnerEpoch { t.Fatal(changed, err) } retained, err = s.GetSandboxAllocationSetup(t.Context(), ref) - if err != nil || retained.Generation != 1 || retained.E2B.APIKey != input.E2B.APIKey || retained.E2B.Template != oldTemplate || retained.E2B.APIURL != "https://api.e2b.app" { + if err != nil || retained.Generation != 1 || retained.Configuration.(*e2b.DeploymentConfiguration).APIKey != input.Configuration.(*e2b.DeploymentConfiguration).APIKey || retained.Configuration.(*e2b.DeploymentConfiguration).Template != oldTemplate || retained.Configuration.(*e2b.DeploymentConfiguration).APIURL != "https://api.e2b.app" { t.Fatal("old generation did not use committed key", err) } if _, err = s.pool.Exec(t.Context(), `UPDATE runtime_allocations SET deployment_generation=3 WHERE id=$1`, owner.ID); err == nil { @@ -50,7 +53,7 @@ func TestE2BGenerationsRetainOwnershipAndUseCurrentCredential(t *testing.T) { t.Fatal(err) } generations, err := s.SandboxGenerationPage(t.Context(), -1) - if err != nil || len(generations) != 1 || generations[0].Generation != 1 || generations[0].E2B.APIURL != "https://api.e2b.app" { + if err != nil || len(generations) != 1 || generations[0].Generation != 1 || generations[0].Configuration.(*e2b.DeploymentConfiguration).APIURL != "https://api.e2b.app" { t.Fatal(generations, err) } if _, err = w.RequestRuntimeCleanup(t.Context(), owner); err != nil { @@ -78,40 +81,40 @@ func TestE2BGenerationsRetainOwnershipAndUseCurrentCredential(t *testing.T) { func TestE2BRetainedCustomEndpointAfterOnlineSwitch(t *testing.T) { s, w, view, input := webSpecificationFixture(t, "e2b") ctx := SandboxResetTestContext(t.Context()) - input.E2B.APIURL, input.E2B.Domain = "https://sandbox.example.com", "sandbox.example.com" + input.Configuration.(*e2b.DeploymentConfiguration).APIURL, input.Configuration.(*e2b.DeploymentConfiguration).Domain = "https://sandbox.example.com", "sandbox.example.com" custom, err := w.UpdateSandboxDeployment(ctx, view.InstallationID, SandboxDeploymentUpdateRequest{SandboxDeploymentSetupRequest: input, ExpectedGeneration: view.Generation}) if err != nil || custom.Generation != 2 { t.Fatal(custom, err) } tenant, session := managedArchiveSession(t, s, managerSessionInput(uuid.NewString())) owner := archiveAllocation(t, w, tenant, session, view.InstallationID) - input.E2B.APIURL, input.E2B.Domain = "", "" + input.Configuration.(*e2b.DeploymentConfiguration).APIURL, input.Configuration.(*e2b.DeploymentConfiguration).Domain = "", "" current, err := w.UpdateSandboxDeployment(ctx, view.InstallationID, SandboxDeploymentUpdateRequest{SandboxDeploymentSetupRequest: input, ExpectedGeneration: custom.Generation}) - if err != nil || current.Generation != 3 || current.E2B.APIURL != "https://api.e2b.app" { + if err != nil || current.Generation != 3 || e2bPublicConfiguration(t, current).APIURL != "https://api.e2b.app" { t.Fatal(current, err) } ref := sandbox.Reference{TenantID: tenant, EnvironmentID: owner.EnvironmentID, AllocationID: owner.ID} retained, err := s.GetSandboxAllocationSetup(t.Context(), ref) - if err != nil || retained.Generation != 2 || retained.E2B.APIURL != "https://sandbox.example.com" || retained.E2B.Domain != "sandbox.example.com" { + if err != nil || retained.Generation != 2 || retained.Configuration.(*e2b.DeploymentConfiguration).APIURL != "https://sandbox.example.com" || retained.Configuration.(*e2b.DeploymentConfiguration).Domain != "sandbox.example.com" { t.Fatal(retained, err) } generations, err := s.SandboxGenerationPage(t.Context(), -1) - if err != nil || len(generations) != 1 || generations[0].E2B.APIURL != "https://sandbox.example.com" { + if err != nil || len(generations) != 1 || generations[0].Configuration.(*e2b.DeploymentConfiguration).APIURL != "https://sandbox.example.com" { t.Fatal(generations, err) } } func TestE2BChangeClassifierOmittedKeyAndExplicitSameKey(t *testing.T) { _, w, view, input := webSpecificationFixture(t, "e2b") - key := input.E2B.APIKey - input.E2B.APIKey = "" + key := input.Configuration.(*e2b.DeploymentConfiguration).APIKey + input.Configuration.(*e2b.DeploymentConfiguration).APIKey = "" input.Resources = sandbox.Resources{} resolved, noOp, err := w.ClassifySandboxDeploymentChange(t.Context(), view.InstallationID, SandboxDeploymentUpdateRequest{SandboxDeploymentSetupRequest: input, ExpectedGeneration: 1}) - if err != nil || !noOp || resolved.E2B.APIKey != key || resolved.Resources.CPUs == 0 { + if err != nil || !noOp || resolved.Configuration.(*e2b.DeploymentConfiguration).APIKey != key || resolved.Resources.CPUs == 0 { t.Fatal(noOp, err) } - input.E2B.APIKey = key - input.E2B.ReplaceCredential = true + input.Configuration.(*e2b.DeploymentConfiguration).APIKey = key + input.Configuration.(*e2b.DeploymentConfiguration).CredentialSupplied = true _, noOp, err = w.ClassifySandboxDeploymentChange(t.Context(), view.InstallationID, SandboxDeploymentUpdateRequest{SandboxDeploymentSetupRequest: input, ExpectedGeneration: 1}) if err != nil || noOp { t.Fatal("explicit same key skipped verification", err) @@ -281,7 +284,7 @@ func TestGenerationDowngradeRefusesOldAllocation(t *testing.T) { s, w, view, input := webSpecificationFixture(t, "e2b") tenant, session := managedArchiveSession(t, s, managerSessionInput(uuid.NewString())) owner := archiveAllocation(t, w, tenant, session, view.InstallationID) - input.E2B.Template = "next:" + uuid.NewString() + input.Configuration.(*e2b.DeploymentConfiguration).Template = "next:" + uuid.NewString() if _, err := w.UpdateSandboxDeployment(SandboxResetTestContext(t.Context()), view.InstallationID, SandboxDeploymentUpdateRequest{SandboxDeploymentSetupRequest: input, ExpectedGeneration: 1}); err != nil { t.Fatal(err) } @@ -317,7 +320,7 @@ func TestGenerationUpdateSerializesWithAllocationAdmission(t *testing.T) { s, w, view, input := webSpecificationFixture(t, "e2b") for generation := uint64(1); generation <= 6; generation++ { tenant, session := managedArchiveSession(t, s, managerSessionInput(uuid.NewString())) - input.E2B.Template = "next:" + uuid.NewString() + input.Configuration.(*e2b.DeploymentConfiguration).Template = "next:" + uuid.NewString() start := make(chan struct{}) changed := make(chan error, 1) go func() { diff --git a/services/agents-api/internal/store/sandbox_reset_test.go b/services/agents-api/internal/store/sandbox_reset_test.go index 83e903403..a8f5054cc 100644 --- a/services/agents-api/internal/store/sandbox_reset_test.go +++ b/services/agents-api/internal/store/sandbox_reset_test.go @@ -158,7 +158,7 @@ func TestSandboxResetCancellationABADeadlineAndGeneration(t *testing.T) { t.Fatal("cancelled reset finalized successor", err) } empty, err := w.CompleteSandboxReset(ctx, installation, 1, second.Reset.RequestedAt) - if err != nil || empty.Provider != "" || empty.Generation != 2 || empty.Reset != nil || empty.InstallationID != installation || empty.E2B != nil || empty.Specification != nil { + if err != nil || empty.Provider != "" || empty.Generation != 2 || empty.Reset != nil || empty.InstallationID != installation || empty.Configuration != nil || empty.Specification != nil { t.Fatal("reset commit", empty, err) } if _, err := w.CancelSandboxReset(ctx, installation, 1); !errors.Is(err, ErrSandboxDeploymentConflict) { diff --git a/services/agents-api/internal/store/sandbox_specification.go b/services/agents-api/internal/store/sandbox_specification.go index ce7041d4e..a0c66ce16 100644 --- a/services/agents-api/internal/store/sandbox_specification.go +++ b/services/agents-api/internal/store/sandbox_specification.go @@ -42,7 +42,7 @@ func SandboxSetupForSelection(installationID string, input SandboxDeploymentSetu if err != nil { return SandboxSetup{}, sandboxConfigurationError(err) } - result := SandboxSetup{InstallationID: installationID, Provider: input.Provider, Mode: description.Mode, Specification: normalized.DeploymentSpec, E2B: normalized.E2B, BackendFingerprint: description.BackendFingerprint, IdleSeconds: description.IdleSeconds, RetentionSeconds: description.RetentionSeconds} + result := SandboxSetup{InstallationID: installationID, Provider: input.Provider, Mode: description.Mode, Specification: normalized.DeploymentSpec, Configuration: normalized.Configuration, BackendFingerprint: description.BackendFingerprint, IdleSeconds: description.IdleSeconds, RetentionSeconds: description.RetentionSeconds} return result, nil } diff --git a/services/agents-api/internal/store/sandbox_specification_store_test.go b/services/agents-api/internal/store/sandbox_specification_store_test.go index 14b421d68..fcf4dbc22 100644 --- a/services/agents-api/internal/store/sandbox_specification_store_test.go +++ b/services/agents-api/internal/store/sandbox_specification_store_test.go @@ -8,6 +8,8 @@ import ( "strings" "testing" + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/sandbox/e2b" + "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/device" "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/credentialcrypto" "github.com/google/uuid" @@ -61,7 +63,7 @@ func TestSandboxSpecificationRoundTripAndFileConfigurationCannotOverride(t *test t.Fatal("saved deployment lost its resources or Runtime provenance", err) } preview, err := SandboxSetupForSelection(view.InstallationID, input) - if err != nil || preview.Mode != setup.Mode || preview.BackendFingerprint != setup.BackendFingerprint || preview.IdleSeconds != setup.IdleSeconds || preview.RetentionSeconds != setup.RetentionSeconds || !reflect.DeepEqual(preview.E2B, setup.E2B) { + if err != nil || preview.Mode != setup.Mode || preview.BackendFingerprint != setup.BackendFingerprint || preview.IdleSeconds != setup.IdleSeconds || preview.RetentionSeconds != setup.RetentionSeconds || !reflect.DeepEqual(preview.Configuration, setup.Configuration) { t.Fatal("preview and persisted normalized deployment disagree", err) } input.ExpectedGeneration = view.Generation @@ -273,15 +275,15 @@ func TestSandboxSpecificationChangesPreserveEveryRetainedResource(t *testing.T) func TestSandboxSpecificationInitialCredentialRemainsPrivate(t *testing.T) { s, _, view, input := webSpecificationFixture(t, "e2b") raw, err := json.Marshal(view) - if err != nil || bytes.Contains(raw, []byte(input.E2B.APIKey)) || bytes.Contains(raw, []byte("api_key")) { + if err != nil || bytes.Contains(raw, []byte(input.Configuration.(*e2b.DeploymentConfiguration).APIKey)) || bytes.Contains(raw, []byte("api_key")) { t.Fatal("public deployment serialized a private credential", err) } var stored []byte - if err := s.pool.QueryRow(t.Context(), "SELECT e2b_credential FROM runtime_deployment").Scan(&stored); err != nil || len(stored) == 0 || bytes.Contains(stored, []byte(input.E2B.APIKey)) { + if err := s.pool.QueryRow(t.Context(), "SELECT provider_credential FROM runtime_deployment").Scan(&stored); err != nil || len(stored) == 0 || bytes.Contains(stored, []byte(input.Configuration.(*e2b.DeploymentConfiguration).APIKey)) { t.Fatal("private credential was not encrypted", err) } // The credential is rejected before the cloud deployment mode is reported. - if _, err := s.RuntimeNodeConfiguration(t.Context(), "", input.E2B.APIKey); !errors.Is(err, ErrRuntimeNodeCredential) { + if _, err := s.RuntimeNodeConfiguration(t.Context(), "", input.Configuration.(*e2b.DeploymentConfiguration).APIKey); !errors.Is(err, ErrRuntimeNodeCredential) { t.Fatal("cloud key authorized node bootstrap", err) } } diff --git a/services/agents-api/internal/store/sandbox_specification_test.go b/services/agents-api/internal/store/sandbox_specification_test.go index 7ac27da68..9e60e8cb6 100644 --- a/services/agents-api/internal/store/sandbox_specification_test.go +++ b/services/agents-api/internal/store/sandbox_specification_test.go @@ -1,8 +1,9 @@ package store import ( - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/sandbox" "strings" + + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/sandbox" ) func SandboxDeploymentTestSpec(provider string) sandbox.DeploymentSpec { diff --git a/services/agents-api/internal/store/session_model_execution_test.go b/services/agents-api/internal/store/session_model_execution_test.go index 67481c70b..88d9fb916 100644 --- a/services/agents-api/internal/store/session_model_execution_test.go +++ b/services/agents-api/internal/store/session_model_execution_test.go @@ -4,10 +4,11 @@ import ( "bytes" "encoding/json" "errors" + "testing" + v1 "github.com/MiniMax-AI-Dev/parsar/contracts/agents-api/v1" "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/credentialcrypto" "github.com/google/uuid" - "testing" ) func TestSessionModelExecutionEncryptedAndBound(t *testing.T) { diff --git a/services/agents-api/internal/store/token_usage.go b/services/agents-api/internal/store/token_usage.go index 9c4414011..3c053bae4 100644 --- a/services/agents-api/internal/store/token_usage.go +++ b/services/agents-api/internal/store/token_usage.go @@ -4,12 +4,13 @@ import ( "context" "encoding/json" "fmt" + "math" + "strings" + v1 "github.com/MiniMax-AI-Dev/parsar/contracts/agents-api/v1" "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/db/sqlc" "github.com/jackc/pgx/v5/pgtype" - "math" - "strings" ) // MeasuredSessionUsage returns Core-internal measured usage for Runtime diff --git a/services/agents-api/internal/store/token_usage_integration_test.go b/services/agents-api/internal/store/token_usage_integration_test.go index 3304a3e8f..e7e68f411 100644 --- a/services/agents-api/internal/store/token_usage_integration_test.go +++ b/services/agents-api/internal/store/token_usage_integration_test.go @@ -5,11 +5,12 @@ import ( "encoding/json" "errors" "fmt" + "testing" + v1 "github.com/MiniMax-AI-Dev/parsar/contracts/agents-api/v1" "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" "github.com/google/uuid" "github.com/jackc/pgx/v5/pgxpool" - "testing" ) func TestTokenUsageDurableSnapshotsAndSessionTotals(t *testing.T) { diff --git a/services/agents-api/migrations/000091_provider_configuration.sql b/services/agents-api/migrations/000091_provider_configuration.sql new file mode 100644 index 000000000..9241d3f39 --- /dev/null +++ b/services/agents-api/migrations/000091_provider_configuration.sql @@ -0,0 +1,117 @@ +-- +goose Up +-- Copy only public selectors/observations. Ciphertext and its installation/generation +-- associated data are unchanged; credentials are never decoded by SQL. +ALTER TABLE runtime_deployment RENAME COLUMN e2b_credential TO provider_credential; +ALTER TABLE runtime_deployment ADD COLUMN provider_config jsonb NOT NULL DEFAULT '{}' CHECK (jsonb_typeof(provider_config)='object'); +ALTER TABLE runtime_deployment ADD COLUMN provider_metadata jsonb NOT NULL DEFAULT '{}' CHECK (jsonb_typeof(provider_metadata)='object'); +UPDATE runtime_deployment SET +provider_config=jsonb_build_object('template',e2b_template,'api_url',e2b_api_url,'domain',e2b_domain), +provider_metadata=jsonb_build_object('template_build',jsonb_build_object('status',e2b_template_build_status,'resources', + jsonb_build_object('cpus',e2b_template_cpus,'memory_mib',e2b_template_memory_mib,'root_disk_mib',e2b_template_root_disk_mib))) +WHERE e2b_template<>''; +ALTER TABLE runtime_deployment DROP COLUMN e2b_template; +ALTER TABLE runtime_deployment DROP COLUMN e2b_api_url; +ALTER TABLE runtime_deployment DROP COLUMN e2b_domain; +ALTER TABLE runtime_deployment DROP COLUMN e2b_template_build_status; +ALTER TABLE runtime_deployment DROP COLUMN e2b_template_cpus; +ALTER TABLE runtime_deployment DROP COLUMN e2b_template_memory_mib; +ALTER TABLE runtime_deployment DROP COLUMN e2b_template_root_disk_mib; +ALTER TABLE runtime_deployment_generations ADD COLUMN provider_config jsonb NOT NULL DEFAULT '{}' CHECK (jsonb_typeof(provider_config)='object'); +ALTER TABLE runtime_deployment_generations ADD COLUMN provider_metadata jsonb NOT NULL DEFAULT '{}' CHECK (jsonb_typeof(provider_metadata)='object'); +ALTER TABLE runtime_deployment_generations DISABLE TRIGGER immutable_sandbox_specification; +UPDATE runtime_deployment_generations SET +provider_config=jsonb_build_object('template',e2b_template,'api_url',e2b_api_url,'domain',e2b_domain), +provider_metadata=jsonb_build_object('template_build',jsonb_build_object('status',e2b_template_build_status,'resources', + jsonb_build_object('cpus',e2b_template_cpus,'memory_mib',e2b_template_memory_mib,'root_disk_mib',e2b_template_root_disk_mib))) +WHERE e2b_template<>''; +ALTER TABLE runtime_deployment_generations ENABLE TRIGGER immutable_sandbox_specification; +ALTER TABLE runtime_deployment_generations DROP COLUMN e2b_template; +ALTER TABLE runtime_deployment_generations DROP COLUMN e2b_api_url; +ALTER TABLE runtime_deployment_generations DROP COLUMN e2b_domain; +ALTER TABLE runtime_deployment_generations DROP COLUMN e2b_template_build_status; +ALTER TABLE runtime_deployment_generations DROP COLUMN e2b_template_cpus; +ALTER TABLE runtime_deployment_generations DROP COLUMN e2b_template_memory_mib; +ALTER TABLE runtime_deployment_generations DROP COLUMN e2b_template_root_disk_mib; + +-- +goose Down +-- Reject a lossy downgrade; no unknown provider configuration may be discarded. +-- +goose StatementBegin +DO $$ BEGIN +IF EXISTS (SELECT 1 FROM runtime_deployment WHERE provider_kind NOT IN ('','docker','microsandbox','e2b')) + OR EXISTS (SELECT 1 FROM runtime_deployment_generations WHERE provider_kind NOT IN ('docker','microsandbox','e2b')) THEN + RAISE EXCEPTION 'Cannot downgrade provider configurations unknown to the previous schema'; +END IF; +END $$; +-- +goose StatementEnd +-- +goose StatementBegin +DO $$ BEGIN +IF EXISTS (SELECT 1 FROM runtime_deployment WHERE + (provider_kind<>'e2b' AND (provider_config<>'{}'::jsonb OR provider_metadata<>'{}'::jsonb)) OR + (provider_kind='e2b' AND (provider_config - ARRAY['template','api_url','domain'] <> '{}'::jsonb OR + provider_metadata - 'template_build' <> '{}'::jsonb OR + COALESCE(provider_metadata->'template_build','{}'::jsonb) - ARRAY['status','resources'] <> '{}'::jsonb OR + COALESCE(provider_metadata#>'{template_build,resources}','{}'::jsonb) - ARRAY['cpus','memory_mib','root_disk_mib'] <> '{}'::jsonb))) THEN + RAISE EXCEPTION 'Cannot downgrade provider configuration without losing fields'; +END IF; +END $$; +-- +goose StatementEnd +ALTER TABLE runtime_deployment ADD COLUMN e2b_template text NOT NULL DEFAULT ''; +ALTER TABLE runtime_deployment ADD COLUMN e2b_api_url text NOT NULL DEFAULT ''; +ALTER TABLE runtime_deployment ADD COLUMN e2b_domain text NOT NULL DEFAULT ''; +ALTER TABLE runtime_deployment ADD COLUMN e2b_template_build_status text; +ALTER TABLE runtime_deployment ADD COLUMN e2b_template_cpus integer; +ALTER TABLE runtime_deployment ADD COLUMN e2b_template_memory_mib integer; +ALTER TABLE runtime_deployment ADD COLUMN e2b_template_root_disk_mib integer; +UPDATE runtime_deployment SET + e2b_template=COALESCE(provider_config->>'template',''),e2b_api_url=COALESCE(provider_config->>'api_url',''),e2b_domain=COALESCE(provider_config->>'domain',''), + e2b_template_build_status=provider_metadata#>>'{template_build,status}', + e2b_template_cpus=(provider_metadata#>>'{template_build,resources,cpus}')::integer, + e2b_template_memory_mib=(provider_metadata#>>'{template_build,resources,memory_mib}')::integer, + e2b_template_root_disk_mib=(provider_metadata#>>'{template_build,resources,root_disk_mib}')::integer; +ALTER TABLE runtime_deployment DROP COLUMN provider_config, DROP COLUMN provider_metadata; +-- +goose StatementBegin +DO $$ BEGIN +IF EXISTS (SELECT 1 FROM runtime_deployment_generations WHERE + (provider_kind<>'e2b' AND (provider_config<>'{}'::jsonb OR provider_metadata<>'{}'::jsonb)) OR + (provider_kind='e2b' AND (provider_config - ARRAY['template','api_url','domain'] <> '{}'::jsonb OR + provider_metadata - 'template_build' <> '{}'::jsonb OR + COALESCE(provider_metadata->'template_build','{}'::jsonb) - ARRAY['status','resources'] <> '{}'::jsonb OR + COALESCE(provider_metadata#>'{template_build,resources}','{}'::jsonb) - ARRAY['cpus','memory_mib','root_disk_mib'] <> '{}'::jsonb))) THEN + RAISE EXCEPTION 'Cannot downgrade provider configuration without losing fields'; +END IF; +END $$; +-- +goose StatementEnd +ALTER TABLE runtime_deployment_generations ADD COLUMN e2b_template text NOT NULL DEFAULT ''; +ALTER TABLE runtime_deployment_generations ADD COLUMN e2b_api_url text NOT NULL DEFAULT ''; +ALTER TABLE runtime_deployment_generations ADD COLUMN e2b_domain text NOT NULL DEFAULT ''; +ALTER TABLE runtime_deployment_generations ADD COLUMN e2b_template_build_status text; +ALTER TABLE runtime_deployment_generations ADD COLUMN e2b_template_cpus integer; +ALTER TABLE runtime_deployment_generations ADD COLUMN e2b_template_memory_mib integer; +ALTER TABLE runtime_deployment_generations ADD COLUMN e2b_template_root_disk_mib integer; +ALTER TABLE runtime_deployment_generations DISABLE TRIGGER immutable_sandbox_specification; +UPDATE runtime_deployment_generations SET + e2b_template=COALESCE(provider_config->>'template',''),e2b_api_url=COALESCE(provider_config->>'api_url',''),e2b_domain=COALESCE(provider_config->>'domain',''), + e2b_template_build_status=provider_metadata#>>'{template_build,status}', + e2b_template_cpus=(provider_metadata#>>'{template_build,resources,cpus}')::integer, + e2b_template_memory_mib=(provider_metadata#>>'{template_build,resources,memory_mib}')::integer, + e2b_template_root_disk_mib=(provider_metadata#>>'{template_build,resources,root_disk_mib}')::integer; +ALTER TABLE runtime_deployment_generations ENABLE TRIGGER immutable_sandbox_specification; +ALTER TABLE runtime_deployment_generations DROP COLUMN provider_config, DROP COLUMN provider_metadata; +ALTER TABLE runtime_deployment RENAME COLUMN provider_credential TO e2b_credential; +ALTER TABLE runtime_deployment ADD CONSTRAINT runtime_deployment_e2b_check CHECK ( + (provider_kind <> '' AND e2b_template <> '' AND e2b_credential IS NOT NULL) OR + (e2b_template = '' AND e2b_credential IS NULL) +); +ALTER TABLE runtime_deployment ADD CONSTRAINT runtime_deployment_e2b_template_build_check CHECK ( + e2b_template <> '' OR (e2b_template_build_status IS NULL AND e2b_template_cpus IS NULL AND + e2b_template_memory_mib IS NULL AND e2b_template_root_disk_mib IS NULL) +); +ALTER TABLE runtime_deployment ADD CONSTRAINT runtime_deployment_e2b_endpoint_check CHECK ( + (e2b_api_url = '' AND e2b_domain = '') OR + (e2b_template <> '' AND e2b_api_url <> '' AND e2b_domain <> '') +); +ALTER TABLE runtime_deployment_generations ADD CONSTRAINT runtime_deployment_generation_e2b_endpoint_check CHECK ( + (e2b_api_url = '' AND e2b_domain = '') OR + (e2b_template <> '' AND e2b_api_url <> '' AND e2b_domain <> '') +); + diff --git a/services/agents-api/tools/microsandbox-provider/command_test.go b/services/agents-api/tools/microsandbox-provider/command_test.go index 90bd28b18..c1055ae9a 100644 --- a/services/agents-api/tools/microsandbox-provider/command_test.go +++ b/services/agents-api/tools/microsandbox-provider/command_test.go @@ -5,11 +5,12 @@ package main import ( "context" "errors" + "strings" + "testing" + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/sandbox" wire "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/sandbox/microsandbox" sdk "github.com/superradcompany/microsandbox/sdk/go" - "strings" - "testing" ) func TestCommandRequiresExitAndSuccessfulStdin(t *testing.T) { diff --git a/services/agents-api/tools/microsandbox-provider/lock_test.go b/services/agents-api/tools/microsandbox-provider/lock_test.go index 0a76639cc..19e7da876 100644 --- a/services/agents-api/tools/microsandbox-provider/lock_test.go +++ b/services/agents-api/tools/microsandbox-provider/lock_test.go @@ -5,11 +5,12 @@ package main import ( "context" "errors" - wire "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/sandbox/microsandbox" "os" "path/filepath" "testing" "time" + + wire "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/sandbox/microsandbox" ) func TestAllocationLockSurvivesCallerDeadlineUntilExplicitSettlement(t *testing.T) { diff --git a/services/agents-api/tools/microsandbox-provider/observed_test.go b/services/agents-api/tools/microsandbox-provider/observed_test.go index 5fdad8ff3..3acd087bd 100644 --- a/services/agents-api/tools/microsandbox-provider/observed_test.go +++ b/services/agents-api/tools/microsandbox-provider/observed_test.go @@ -5,10 +5,11 @@ package main import ( "encoding/json" "errors" + "testing" + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/sandbox" wire "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/sandbox/microsandbox" sdk "github.com/superradcompany/microsandbox/sdk/go" - "testing" ) func TestRestoredOwnershipUsesExactParentWithoutLabels(t *testing.T) { From 8ad166e8d8c39ebb79d2197afd9be0b7b06a0de4 Mon Sep 17 00:00:00 2001 From: saladday <1203511142@qq.com> Date: Wed, 30 Sep 2026 16:04:18 +0800 Subject: [PATCH 2/5] Migrate operator clients and document the provider configuration contract --- CONTRIBUTING.md | 2 + apps/docs/content/docs/admin-api.mdx | 4 +- .../api-reference/core/sandbox-manager.mdx | 15 +- apps/docs/content/docs/public-api.mdx | 2 +- apps/docs/content/docs/sandbox-provider.mdx | 29 +- apps/docs/content/guide-sources.json | 12 +- apps/docs/openapi/core-api.yaml | 255 ++++++------------ apps/docs/openapi/sources.json | 6 +- apps/web/e2e/fixture-console.mjs | 26 +- apps/web/e2e/nodes.spec.ts | 9 +- apps/web/e2e/sandbox-generation.spec.ts | 6 +- .../fleet/SandboxResetNotice.test.tsx | 2 +- .../src/features/fleet/fleet-queries.test.ts | 2 +- .../features/fleet/use-sandbox-fleet.test.tsx | 2 +- .../features/metrics/SandboxMetricsPage.tsx | 4 +- .../src/features/overview/OverviewPage.tsx | 2 +- .../features/overview/getting-started.test.ts | 4 +- .../src/features/overview/getting-started.ts | 2 +- .../sandbox/SandboxDeploymentSettings.tsx | 12 +- .../sandbox/SandboxResetControls.test.tsx | 2 +- .../sandbox/SandboxRolloutSummary.test.tsx | 2 +- .../features/sandbox/SandboxSetupWizard.tsx | 14 +- .../sandbox/deployment-specification.ts | 2 +- .../sandbox/sandbox-deployment-write.test.ts | 2 +- .../sandbox/sandbox-page-ownership.test.tsx | 2 +- .../features/sandbox/sandbox-queries.test.ts | 2 +- .../features/sandbox/sandbox-update.test.ts | 4 +- .../src/features/sandbox/sandbox-update.ts | 4 +- .../use-sandbox-manager-state.test.tsx | 2 +- apps/web/src/i18n/locales/en/core-errors.ts | 8 +- .../web/src/i18n/locales/zh-CN/core-errors.ts | 8 +- apps/web/src/lib/core-error.test.ts | 2 +- apps/web/src/lib/core-error.ts | 2 +- apps/web/src/lib/sandbox-labels.test.ts | 2 +- contracts/agents-api/core-errors.md | 8 +- contracts/agents-api/core.openapi.yaml | 233 +++++----------- contracts/agents-api/sandbox-deployment.md | 57 ++-- docs/api/README.md | 2 +- docs/api/web-management.md | 4 +- docs/sandbox-provider.md | 29 +- docs/web/protocol-coverage.md | 2 +- .../agents-client/src/sandbox-client.test.ts | 46 ++-- packages/agents-client/src/sandbox-client.ts | 66 ++--- .../src/sandbox-e2b-error-params.test.ts | 20 +- .../agents-api/cmd/server/managed_setup.go | 2 +- .../cmd/server/runtime_history_test.go | 5 +- .../cmd/specification-contract/main.go | 3 +- services/agents-api/internal/api/items.go | 3 +- .../api/sandbox_node_configuration.go | 3 +- .../internal/api/sandbox_node_detail_test.go | 3 +- .../agents-api/internal/engine/mcp_test.go | 3 +- .../environment_capabilities_test.go | 5 +- .../internal/execution/environment_test.go | 3 +- .../execution/runtime_capabilities_test.go | 3 +- .../runtimeenrollment/connection_test.go | 5 +- .../internal/runtimeobs/operations_test.go | 3 +- .../agents-api/internal/runtimeobs/service.go | 3 +- .../internal/sandbox/configuration.go | 4 +- .../internal/sandbox/contracttest/provider.go | 5 +- .../sandbox/e2b/configuration_test.go | 73 +++++ .../internal/sandbox/e2b/contract_test.go | 3 +- .../sandbox/microsandbox/contract_test.go | 3 +- .../internal/sandbox/microsandbox/process.go | 3 +- .../sandbox/microsandbox/process_test.go | 3 +- .../agents-api/internal/sandbox/node/agent.go | 3 +- .../sandbox/node/observations_test.go | 3 +- .../internal/sandbox/node/operations_test.go | 3 +- .../internal/sandbox/node/recovery_test.go | 7 +- .../agents-api/internal/sandbox/node/wire.go | 3 +- .../agents-api/internal/sandbox/operations.go | 3 +- .../internal/sandbox/operations_test.go | 5 +- .../sandbox/providers/configuration.go | 3 + .../providers/configuration_flow_test.go | 155 +++++++++++ .../sandbox/providers/configuration_test.go | 38 +++ .../providers/deployment_contract_test.go | 3 +- .../internal/sandbox/providers/export_test.go | 13 + .../internal/sandbox/providers/operations.go | 3 +- .../sandbox/providers/operations_test.go | 3 +- .../internal/store/admin_session_archive.go | 3 +- .../store/environment_initialization_test.go | 13 +- .../internal/store/environment_templates.go | 5 +- .../store/environment_templates_test.go | 3 +- .../agents-api/internal/store/export_test.go | 3 +- .../store/function_item_events_test.go | 3 +- .../store/message_input_helpers_test.go | 3 +- .../provider_configuration_migration_test.go | 81 ++++++ .../agents-api/internal/store/public_url.go | 5 +- .../store/runtime_initialization_test.go | 3 +- .../store/runtime_node_generations_test.go | 5 +- .../internal/store/runtime_placements.go | 12 +- .../store/sandbox_deployment_setup.go | 8 - .../store/sandbox_specification_test.go | 3 +- .../store/session_model_execution_test.go | 3 +- .../agents-api/internal/store/token_usage.go | 5 +- .../store/token_usage_integration_test.go | 3 +- .../microsandbox-provider/command_test.go | 5 +- .../tools/microsandbox-provider/lock_test.go | 3 +- .../microsandbox-provider/observed_test.go | 3 +- 98 files changed, 811 insertions(+), 665 deletions(-) create mode 100644 services/agents-api/internal/sandbox/e2b/configuration_test.go create mode 100644 services/agents-api/internal/sandbox/providers/configuration_flow_test.go create mode 100644 services/agents-api/internal/sandbox/providers/configuration_test.go create mode 100644 services/agents-api/internal/sandbox/providers/export_test.go create mode 100644 services/agents-api/internal/store/provider_configuration_migration_test.go diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 258f02624..1171db8d1 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -201,6 +201,8 @@ The Runtime is not a sandbox; see - Keep component boundaries explicit through shared interfaces and versioned protocols. Register implementations behind those interfaces. Adding an implementation must not require a new orchestration path selected by its name. + Sandbox configuration semantics belong to typed adapter codecs: Core transports + only their safe public projection, observations and separately encrypted secrets. Sandbox registration, configuration adaptation and persistence boundaries follow the [Sandbox Provider guide](docs/sandbox-provider.md#register-the-provider-kind). Resource operation declarations are exhaustive and validated against the existing diff --git a/apps/docs/content/docs/admin-api.mdx b/apps/docs/content/docs/admin-api.mdx index 1bdef28a1..6cd10d3dd 100644 --- a/apps/docs/content/docs/admin-api.mdx +++ b/apps/docs/content/docs/admin-api.mdx @@ -121,11 +121,11 @@ Response `resources.allocations` and `resources.pending` are cleanup counts, not CPU, memory or disk settings. E2B accepts a write-only key and exact template build instead of a node Runtime release, and provisions without a node installation. E2B may omit `resources` to adopt the validated build's CPU and memory. An E2B-compatible -service may also supply paired `e2b.api_url` and `e2b.domain`; omitted selectors +service may also supply paired `configuration.api_url` and `configuration.domain`; omitted selectors use official E2B. Responses expose these addresses but never the key, and changing them requires the same drained maintenance transition as changing the template. Responses show -the build as read at selection time in `e2b.template_build`. Microsandbox responses +the build as read at selection time in `metadata.template_build`. Microsandbox responses return its idle `suspension` policy; other providers return null. Same-team E2B updates apply online after verification. Existing sandboxes retain diff --git a/apps/docs/content/docs/api-reference/core/sandbox-manager.mdx b/apps/docs/content/docs/api-reference/core/sandbox-manager.mdx index de80e6d5a..55e349eac 100644 --- a/apps/docs/content/docs/api-reference/core/sandbox-manager.mdx +++ b/apps/docs/content/docs/api-reference/core/sandbox-manager.mdx @@ -46,14 +46,6 @@ _openapi: Restores admission but never restores Sessions already archived. With no reset running this is an idempotent read, provided the generation still matches. - - content: >- - Core key only. Uses a transient E2B credential and endpoint through - the pinned SDK helper; returns safe template metadata. Does not save - the credential or allocate compute. - - content: >- - Core key only. Reads one template through the pinned SDK helper with a - transient E2B credential. Returns ready builds only, without - allocating compute. - content: >- Core key only. Does not grant project resource access. Responses contain only explicit safe fields. @@ -72,8 +64,13 @@ _openapi: - content: >- Core key only. Does not grant project resource access. Responses contain only explicit safe fields. + - content: >- + Core key only. Uses transient write-only credentials. The provider + validates configuration and query fields and returns safe catalog + metadata. Does not save credentials, change a deployment or allocate + compute. Discovery is not deployment admission. --- {/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */} - \ No newline at end of file + \ No newline at end of file diff --git a/apps/docs/content/docs/public-api.mdx b/apps/docs/content/docs/public-api.mdx index a33e83b6b..9002e88e0 100644 --- a/apps/docs/content/docs/public-api.mdx +++ b/apps/docs/content/docs/public-api.mdx @@ -77,7 +77,7 @@ Errors use the [Core error envelope](https://github.com/MiniMax-AI/parsar-core/b | `projects/{project_id}/environments/{environment_id}/executor-credentials[/{key_id}]` | Executor credentials for a self-hosted Environment | [Executor credentials](https://github.com/MiniMax-AI/parsar-core/blob/f6d258735fc601c521dd990e6f9e1ed261f4ef2d/contracts/agents-api/environment-executor-credentials.md) | | `installation` | Public URL, API base URL, source commit, the installer's process settings and what is bound to the public URL; available before any deployment | [Installation](https://github.com/MiniMax-AI/parsar-core/blob/f6d258735fc601c521dd990e6f9e1ed261f4ef2d/contracts/agents-api/installation.md) | | `metrics` | Core's own process metrics | [Core metrics](https://github.com/MiniMax-AI/parsar-core/blob/f6d258735fc601c521dd990e6f9e1ed261f4ef2d/contracts/agents-api/core-metrics.md) | -| `sandbox/deployment[/reset]`, `sandbox/e2b/templates[/{template_id}/builds]`, `sandbox/enrollment-tokens`, `sandbox/nodes[/{node_id}[/allocations]]` | Sandbox deployment, read-only E2B template discovery, node enrollment tokens and nodes | [Sandbox deployment](https://github.com/MiniMax-AI/parsar-core/blob/f6d258735fc601c521dd990e6f9e1ed261f4ef2d/contracts/agents-api/sandbox-deployment.md), [node operations](https://github.com/MiniMax-AI/parsar-core/blob/f6d258735fc601c521dd990e6f9e1ed261f4ef2d/services/agents-api/HOSTED-SANDBOX-MANAGER.md), [node host history](https://github.com/MiniMax-AI/parsar-core/blob/f6d258735fc601c521dd990e6f9e1ed261f4ef2d/contracts/agents-api/node-host-history.md) | +| `sandbox/deployment[/reset]`, `sandbox/providers/{provider}/discovery`, `sandbox/enrollment-tokens`, `sandbox/nodes[/{node_id}[/allocations]]` | Sandbox deployment, read-only provider configuration discovery, node enrollment tokens and nodes | [Sandbox deployment](https://github.com/MiniMax-AI/parsar-core/blob/f6d258735fc601c521dd990e6f9e1ed261f4ef2d/contracts/agents-api/sandbox-deployment.md), [node operations](https://github.com/MiniMax-AI/parsar-core/blob/f6d258735fc601c521dd990e6f9e1ed261f4ef2d/services/agents-api/HOSTED-SANDBOX-MANAGER.md), [node host history](https://github.com/MiniMax-AI/parsar-core/blob/f6d258735fc601c521dd990e6f9e1ed261f4ef2d/contracts/agents-api/node-host-history.md) | | `harnesses`, `harnesses/{harness}/model-configuration` | Supported harnesses and each harness's deployment default model configuration (write-only provider key) | [Model execution](https://github.com/MiniMax-AI/parsar-core/blob/f6d258735fc601c521dd990e6f9e1ed261f4ef2d/contracts/agents-api/model-execution.md#deployment-defaults) | ## Machine connection API diff --git a/apps/docs/content/docs/sandbox-provider.mdx b/apps/docs/content/docs/sandbox-provider.mdx index c36582f5e..83c37ca41 100644 --- a/apps/docs/content/docs/sandbox-provider.mdx +++ b/apps/docs/content/docs/sandbox-provider.mdx @@ -205,7 +205,7 @@ A provider must not implement a competing preparation path. ## Register the provider kind `sandbox/providers/registry.go` is the sole registration table. Each entry binds -an adapter's specification/resource validators, selection normalization, deployment +an adapter's specification/resource validators, the required `ConfigurationAdapter`, deployment mode, defaults, the adapter-owned operation declaration, and local or direct constructor. `providers.Build` constructs node-local adapters; `providers.BuildDirect` constructs direct adapters. Neither allocates compute. There is no init-time registration or @@ -216,7 +216,7 @@ For a new implementation: 1. Implement the operation contracts above in the adapter package and add native contract tests. 2. Add its configuration validators and optional read-only `SelectionDiscoverer` for native resource discovery. Normalization must copy input before changing it. - `RestoreSelection` must retain access to owned resources without requiring new + `ConfigurationAdapter.Decode` must retain access to owned resources without requiring new template validation. Put native credential verification behind `CredentialVerifier` when needed. 3. Register its constructor, policies, operation declaration and defaults in `providers/registry.go`. @@ -224,18 +224,31 @@ For a new implementation: The installer projection uses those registered policies and the common field bounds in `sandbox/deployment_contract.go`; regenerate it with `go run ./services/agents-api/cmd/specification-contract -write`. -4. If new configuration fields are necessary, extend the typed `sandbox.Selection` - envelope and its dedicated encrypted persistence fields, API DTO and operator - client. Do not replace typed configuration with unrestricted JSON. Field codecs - may map columns; Store must not parse native endpoints, templates or defaults. +4. Implement `sandbox.ConfigurationAdapter` with a typed native configuration. + `DecodeInput` strictly parses separate `configuration` and write-only `credential` + objects. `Encode` creates whitelisted public selectors, read-only observations + and separate secret bytes; it must never pass request JSON through. `Decode` + restores stored selectors without remote admission. `ResolveChange`, `Equal` + and `WithCredential` own inheritance, identity and credential composition. + `Requirements` explicitly declares credential and public-origin needs plus + discovery support. Node providers accept only an empty public object, reject + credentials and explicitly return Unsupported for discovery and replacement. + Implement the separate `ConfigurationDiscoverer` interface even when unsupported. + Discovery owns query validation and safe catalog results, never mutations or + deployment admission. Core keeps admin authorization, input limits and deadlines. + A new kind requires no vendor column, API field or Store branch. 5. Supply required installer/distribution artifacts and operator labels. A new provider must not add a Session/Turn scheduling path or a Store vendor switch. Preview and persistence use `providers.Normalize` and `providers.Describe`. `SelectionDiscoverer` resolves omitted native resource values before commit; the complete specification is validated again at persistence. Store owns transactions, -credential encryption, generation fencing, resource ownership and typed column -mapping. Database constraints validate structure, not the registration list. +credential encryption, generation fencing, resource ownership and generic object storage. The adapter alone interprets +`provider_config` and `provider_metadata`; `provider_credential` holds ciphertext +bound to the installation and generation. Retained generations store their original +public configuration and metadata, and compose the current credential through the +adapter. No retained selector is rewritten by a credential replacement. Database +constraints validate object structure, not the registration list. `providers.ResolveChange` owns configuration inheritance and comparison uses normalized selectors, so preview, retry and commit share the same defaults. diff --git a/apps/docs/content/guide-sources.json b/apps/docs/content/guide-sources.json index 76771d001..5b1a1562a 100644 --- a/apps/docs/content/guide-sources.json +++ b/apps/docs/content/guide-sources.json @@ -9,7 +9,7 @@ "docs/configuration.md": "7dc0031145bb5811bf22cab15270b511b1c43b1bd1ee2b71175d3bc848751bd6", "docs/web/core-connection.md": "861c75c32676fd17b84eb356b263096703af5750c1ceb71bb339e84607fffc56", "docs/getting-started/quickstart.md": "b989682ac2e58d59a794118fd371b37d1ea64c957d3512ff53739458a95d0f9a", - "docs/api/README.md": "faada41906e584a2dfcd1efb0af893e6039cd456eff989f019e0dd816dc419ff", + "docs/api/README.md": "90076c55b1a03f0f1cfd9373a6d005c8caa3c4e58359e9e865ee2c13c8dbe9ec", "contracts/agents-api/execution-tools.md": "fe1e3cf471fe9c7ef04afa7230e6b7742fbf2146c74bd944a7a22d5d4d4197da", "docs/api/public-agent-api.md": "e1111aaf5215392178246969e281b930374b22ee380d529b08b2482836d6333d", "docs/examples.md": "c12c34adc5b2fa57c81602b23d8ecc8317596f9c5a4aedbf1f1fe934a08a94f5", @@ -18,7 +18,7 @@ "docs/getting-started/self-hosted.md": "653a9132ea6bbc39186f7917fa1596027d091071172e6a6afd9f618fc1dab725", "apps/web/public/onboarding/monitor-en.webp": "29dc220cb1250c7016b7c4bf7f30e9510b07c07b814a48c3aa9303b2d76f20b2", "docs/web/README.md": "47159689b2488f0a94a1af8488bcf98b465e0cca4003d64a4699d7b4db24e086", - "docs/api/web-management.md": "fc064c53874ca2aa6d17e5265763a4a4c744860062c27353cfafe74d3e6ac790", + "docs/api/web-management.md": "18b8e32898bf5dcfab3407377292393cf290ffc3432ab075ff1c980f9b3c383e", "contracts/agents-api/runtime-observability-api.md": "cd46e777fe716a7e76374f4655c96dfbad9d8be1bfca6203ed6118ab87874c2b", "docs/getting-started/operations.md": "4069e89deed7ef619f28cc8d9779055669246eabe322089113ac3a786c3cb2d5", "docs/user-guide.md": "078ad930003dd333e65beb152dab11809b08d58dcc9d5d108c61f60dc3a320e2", @@ -27,7 +27,7 @@ "contracts/agents-api/harness-onboarding.md": "03b069b0c2ae18248cf6b1d1c82b6c3a6cd74719746bd343acb128a86d2c67a2", "docs/runtime-bootstrap.md": "0d49aed73b298039e04453e6f465b0e925fb2227fa35d4206820bb3acbd8df39", "docs/runtime-protocol.md": "4bce016e8ec239281969c84c8483044cf3247051eb062ec30d3effa00b661001", - "docs/sandbox-provider.md": "a1323c7f6a28a4f5f0e823274c08422707a5dab85b11e008ccb28a3aafc02058", + "docs/sandbox-provider.md": "5e0ff707a50f12eaa367dc9f45e0f956526548c865baa96e0254044cde94c698", "apps/docs/scripts/guides.json": "19265370dbcb8e30b125079d0d25fad05b6f783ec368b7ebb6e81389b7853342" }, "outputs": { @@ -39,7 +39,7 @@ "content/docs/configure.mdx": "02d1eee789646fdf65ed2ec48b6fe954c81107d6ff5891536ec6ac2df0a8c4dc", "content/docs/bootstrap-projects-keys.mdx": "91a6f62cbe41737adfce9e8ec56e6dc3e2fb0ec8f6b677576f941cdfb378dc47", "content/docs/quickstart.mdx": "d0ab9537ab68f6dd3109353e937a29c5d58ec3894b56a52873c9a1d79dfa60a6", - "content/docs/public-api.mdx": "cc0bad7611eb2c4f1d7ec231a3f77c37989e268b76423f292eee88ccf25eb892", + "content/docs/public-api.mdx": "6b275f846289442c37a0a6f5d3a0501af3c6fa42556aef665864f24055cb603b", "content/docs/agents-and-tools.mdx": "3ca16d2e2ff1c759251fbdf6e071ff09a93d5efc7acaca36a48854597d4f9576", "content/docs/sessions.mdx": "e8693563738f690c21be92e0ea09e925528bc85bea2c0fb4799c3f1c4074cfba", "content/docs/examples.mdx": "5f1dde8043695c40edf775345159d93b2444d5ce6a109829b78678b0b5de0d46", @@ -48,7 +48,7 @@ "content/docs/self-hosted-execution.mdx": "a8e6500e41c666eacb2c75882b2b8ee0cf122fe19ea36f39ef89f5dcf17b68e1", "public/images/source/apps/web/public/onboarding/monitor-en.webp": "29dc220cb1250c7016b7c4bf7f30e9510b07c07b814a48c3aa9303b2d76f20b2", "content/docs/console.mdx": "a930ce36035de74f0c2bef71bed067fc2287ba1c70a485758bf100d484f6adfd", - "content/docs/admin-api.mdx": "6fcfe4c5b5b70d5322c131ce4ab70f1a7ea8b0033a5f500b20cf27abb338236e", + "content/docs/admin-api.mdx": "7b34a3658b4ddd17be5712bfad35eb39813b05c97b1732077bfb4132a50c7441", "content/docs/observability.mdx": "c81214e1865517a9163c48c7ce7c396f5490c9d162080dfa05fa5ebc147f6c8b", "content/docs/troubleshooting.mdx": "bccd725d2389a80b66caf1a1da80532bd68dd3d470bf851799d0114f84e0af25", "content/docs/user-guide.mdx": "92b839c8d1a2fbf4d35388c414c4524734f32724c30c07395edc994fa11cf702", @@ -57,6 +57,6 @@ "content/docs/harness-onboarding.mdx": "e434d62bd0b558745774c8d729e4d86d2b40ed3026e3d77b777c48aef141c659", "content/docs/runtime-bootstrap.mdx": "58982955811c9ad46a9fc04d8d2aef5a762fc9d25d5833f88aa75ee3fc46523f", "content/docs/runtime-protocol.mdx": "560f887d5fc9fa673275a6481220f5fb997c4ece30c09384b81b14dbc7aa0dd4", - "content/docs/sandbox-provider.mdx": "83a6244fb2140836aa6746c990c9cd04d04e58d5b3a52faf1a84e5e846dfd99f" + "content/docs/sandbox-provider.mdx": "a52a6e7f9b55864363b96eed08d8feb0e2b67276884b800699c9217e699d7b7c" } } diff --git a/apps/docs/openapi/core-api.yaml b/apps/docs/openapi/core-api.yaml index 94179a5b2..baa658bb1 100644 --- a/apps/docs/openapi/core-api.yaml +++ b/apps/docs/openapi/core-api.yaml @@ -3629,93 +3629,6 @@ paths: $ref: '#/components/schemas/store.SandboxResetRequest' description: Reset mode and current deployment generation required: true - /core/v1/sandbox/e2b/templates: - post: - description: Core key only. Uses a transient E2B credential and endpoint through the pinned SDK helper; returns safe template metadata. Does not save the credential or allocate compute. - responses: - '200': - description: OK - content: - application/json: - schema: - $ref: '#/components/schemas/api.SandboxE2BDiscoveryResult' - '400': - description: Bad Request - content: - application/json: - schema: - $ref: '#/components/schemas/api.CoreErrorResponse' - '401': - description: Unauthorized - content: - application/json: - schema: - $ref: '#/components/schemas/api.CoreErrorResponse' - '503': - description: Service Unavailable - content: - application/json: - schema: - $ref: '#/components/schemas/api.CoreErrorResponse' - security: - - DeploymentAdminAuth: [] - summary: List templates visible to an E2B credential - tags: - - Sandbox Manager - requestBody: - content: - application/json: - schema: - $ref: '#/components/schemas/api.SandboxE2BDiscoveryInput' - description: Transient E2B connection - required: true - /core/v1/sandbox/e2b/templates/{template_id}/builds: - post: - description: Core key only. Reads one template through the pinned SDK helper with a transient E2B credential. Returns ready builds only, without allocating compute. - parameters: - - schema: - type: string - description: Template ID - in: path - name: template_id - required: true - responses: - '200': - description: OK - content: - application/json: - schema: - $ref: '#/components/schemas/api.SandboxE2BDiscoveryResult' - '400': - description: Bad Request - content: - application/json: - schema: - $ref: '#/components/schemas/api.CoreErrorResponse' - '401': - description: Unauthorized - content: - application/json: - schema: - $ref: '#/components/schemas/api.CoreErrorResponse' - '503': - description: Service Unavailable - content: - application/json: - schema: - $ref: '#/components/schemas/api.CoreErrorResponse' - security: - - DeploymentAdminAuth: [] - summary: List ready builds for an E2B template - tags: - - Sandbox Manager - requestBody: - content: - application/json: - schema: - $ref: '#/components/schemas/api.SandboxE2BDiscoveryInput' - description: Transient E2B connection - required: true /core/v1/sandbox/enrollment-tokens: post: description: Core key only. Does not grant project resource access. Responses contain only explicit safe fields. @@ -4065,6 +3978,60 @@ paths: summary: List retained allocations on a sandbox node tags: - Sandbox Manager + /core/v1/sandbox/providers/{provider}/discovery: + post: + description: Core key only. Uses transient write-only credentials. The provider validates configuration and query fields and returns safe catalog metadata. Does not save credentials, change a deployment or allocate compute. Discovery is not deployment admission. + parameters: + - schema: + type: string + description: Registered provider kind + in: path + name: provider + required: true + responses: + '200': + description: OK + content: + application/json: + schema: + additionalProperties: true + type: object + '400': + description: Bad Request + content: + application/json: + schema: + $ref: '#/components/schemas/api.CoreErrorResponse' + '401': + description: Unauthorized + content: + application/json: + schema: + $ref: '#/components/schemas/api.CoreErrorResponse' + '500': + description: Internal Server Error + content: + application/json: + schema: + $ref: '#/components/schemas/api.CoreErrorResponse' + '503': + description: Service Unavailable + content: + application/json: + schema: + $ref: '#/components/schemas/api.CoreErrorResponse' + security: + - DeploymentAdminAuth: [] + summary: Discover sandbox provider configuration + tags: + - Sandbox Manager + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/sandbox.ConfigurationDiscoveryInput' + description: Transient provider connection and query + required: true /core/v1/sandbox/runtime-observations: get: description: Core key only. Each observation is labelled with its owning Project ID. Uses the existing read-only Runtime sampler, with bounded concurrency and no execution or provisioning. A provider with a batch metrics read, such as E2B, samples the page's running sandboxes in one bounded request. @@ -4769,8 +4736,10 @@ components: type: object api.SandboxDeploymentChangeInput: properties: - e2b: - $ref: '#/components/schemas/api.SandboxE2BInput' + configuration: + type: object + credential: + type: object expected_generation: type: integer provider: @@ -4788,8 +4757,10 @@ components: type: object api.SandboxDeploymentInput: properties: - e2b: - $ref: '#/components/schemas/api.SandboxE2BInput' + configuration: + type: object + credential: + type: object expected_generation: type: integer provider: @@ -4805,37 +4776,6 @@ components: required: - expected_generation type: object - api.SandboxE2BDiscoveryInput: - properties: - api_key: - type: string - api_url: - type: string - domain: - type: string - type: object - api.SandboxE2BDiscoveryResult: - properties: - builds: - items: - $ref: '#/components/schemas/e2b.ReadyBuild' - type: array - templates: - items: - $ref: '#/components/schemas/e2b.TemplateSummary' - type: array - type: object - api.SandboxE2BInput: - properties: - api_key: - type: string - api_url: - type: string - domain: - type: string - template: - type: string - type: object api.SandboxEnrollmentToken: properties: enrollment_id: @@ -5184,23 +5124,14 @@ components: service: $ref: '#/components/schemas/coremetrics.ServiceState' type: object - e2b.ReadyBuild: - properties: - cpus: - type: integer - id: - type: string - memory_mib: - type: integer - type: object - e2b.TemplateSummary: + sandbox.ConfigurationDiscoveryInput: properties: - id: - type: string - names: - items: - type: string - type: array + configuration: + type: object + credential: + type: object + query: + type: object type: object sandbox.DeploymentSpec: properties: @@ -5446,15 +5377,19 @@ components: type: object store.RuntimeDeploymentView: properties: + configuration: + type: object core_url: description: 'Read-only: the installation public URL (OAC_PUBLIC_URL), which nodes and sandboxes use to reach Core. The deployment API does not accept it.' type: string - e2b: - $ref: '#/components/schemas/store.SandboxE2BView' + credential_configured: + type: boolean generation: type: integer installation_id: type: string + metadata: + type: object mode: type: string owner_epoch: @@ -5685,31 +5620,6 @@ components: pending: type: integer type: object - store.SandboxE2BTemplateBuildView: - properties: - resources: - $ref: '#/components/schemas/store.SandboxTemplateResources' - status: - anyOf: - - type: string - - type: 'null' - description: Build status at selection time; validation admits only ready builds. - type: object - store.SandboxE2BView: - properties: - api_url: - type: string - credential_configured: - type: boolean - domain: - type: string - template: - type: string - template_build: - allOf: - - $ref: '#/components/schemas/store.SandboxE2BTemplateBuildView' - description: The fixed template build as Core read it when this selection was saved. - type: object store.SandboxNodeRollout: properties: diagnostic: @@ -5832,21 +5742,6 @@ components: retention_seconds: type: integer type: object - store.SandboxTemplateResources: - properties: - cpus: - anyOf: - - type: integer - - type: 'null' - memory_mib: - anyOf: - - type: integer - - type: 'null' - root_disk_mib: - anyOf: - - type: integer - - type: 'null' - type: object store.WriteOperation: properties: action: diff --git a/apps/docs/openapi/sources.json b/apps/docs/openapi/sources.json index 05824a2cb..dcf19b1dc 100644 --- a/apps/docs/openapi/sources.json +++ b/apps/docs/openapi/sources.json @@ -1,7 +1,7 @@ { "sources": { "contracts/agents-api/openapi.yaml": "bdda1027eb0e7d2e8ccc5dba3ed3e2dbb6d447e9ab2c5b571fe4056fa7596eec", - "contracts/agents-api/core.openapi.yaml": "f1b9278c3a4b1cb55127cf9471583d00f1ab13b6ceb64f8693a01c4cf03674eb", + "contracts/agents-api/core.openapi.yaml": "d43ae9654213d2b457a01ac0e470a0fba356c5a50052ea57e6dd51518355f81e", "contracts/agents-api/runtime.openapi.yaml": "505286d5eacf94a14f9527fcf4e7beb4fba4f792681be26ba966254cbf49b4aa" }, "outputs": { @@ -40,8 +40,8 @@ "content/docs/api-reference/core/skills.mdx": "ed899ade7b032b1732b3097d81419a8fdbb9ead0594fca9f2107da644b97031f", "content/docs/api-reference/core/vaults.mdx": "12d0aac96cd4a3472ef4eaa287f7945f78862017956e79bfc3ed0ef8e772e78a", "content/docs/api-reference/core/credentials.mdx": "27d586b9a8fd738b9947802f32c80458195370252886bc26abedccf2125ee981", - "content/docs/api-reference/core/sandbox-manager.mdx": "45e65afffb25e5b6ee2ab0853d7a84dee164e0d44ea384675fbd7ffcbd6c1a58", - "openapi/core-api.yaml": "dc8e80ada4ef8bd93635cab2c5d337b9d7661991a6baa1f69dc4258ebe434ef9", + "content/docs/api-reference/core/sandbox-manager.mdx": "ca91a5976b6ff134d29aba226b5e078af4ec9fa7ba67d9a2365664a61c8a8b45", + "openapi/core-api.yaml": "0489659a5afe542e0df25347d4d73a71b6c63d4b5460b533a3332e67db7e0591", "content/docs/api-reference/core/meta.json": "d0aef1f54c4cfc30b4e1d974119ae1cdce1c87d5ecba723afbeb4599119482a7", "content/docs/api-reference/core/index.mdx": "57cf9ea947ef45e79575c25a0a3d4e5e856dde6d14698ccc2c51b86201c249e9", "content/docs/api-reference/machine/native-installation.mdx": "421a858685b79fdcf8d7eff308c5768d1b62385c3988c6089f380afe80aaed69", diff --git a/apps/web/e2e/fixture-console.mjs b/apps/web/e2e/fixture-console.mjs index b546dcff0..d6169cae1 100644 --- a/apps/web/e2e/fixture-console.mjs +++ b/apps/web/e2e/fixture-console.mjs @@ -80,18 +80,18 @@ function nodeRollout(previous = 0) { } function unconfiguredDeployment(generation = 0, ownerEpoch = 3) { - return { installation_id: INSTALLATION_ID, provider: "", core_url: publicUrl(), reset: null, rollout: noNodeRollout(), owner_epoch: ownerEpoch, generation, mode: "", resources: { allocations: 0, pending: 0 }, suspension: null }; + return { installation_id: INSTALLATION_ID, provider: "", credential_configured: false, core_url: publicUrl(), reset: null, rollout: noNodeRollout(), owner_epoch: ownerEpoch, generation, mode: "", resources: { allocations: 0, pending: 0 }, suspension: null }; } function configuredDeployment() { - return { installation_id: INSTALLATION_ID, provider: "docker", core_url: publicUrl(), reset: null, rollout: nodeRollout(), owner_epoch: 3, generation: 1, mode: "nodes", resources: { allocations: 0, pending: 0 }, specification: { resources: { cpus: 2, memory_mib: 4096 }, runtime: release }, specification_digest: "fixture", suspension: null }; + return { installation_id: INSTALLATION_ID, provider: "docker", credential_configured: false, configuration: {}, metadata: {}, core_url: publicUrl(), reset: null, rollout: nodeRollout(), owner_epoch: 3, generation: 1, mode: "nodes", resources: { allocations: 0, pending: 0 }, specification: { resources: { cpus: 2, memory_mib: 4096 }, runtime: release }, specification_digest: "fixture", suspension: null }; } /** The E2B template build as Core read it when the selection was saved. */ const templateBuild = { status: "ready", resources: { cpus: 2, memory_mib: 2048, root_disk_mib: 10240 } }; // E2B runs sandboxes in its cloud: no nodes, only what Core holds there. function e2bDeployment() { - return { ...configuredDeployment(), provider: "e2b", mode: "direct", rollout: noNodeRollout(), resources: { allocations: 3, pending: 1 }, specification: { resources: { cpus: 2, memory_mib: 2048 } }, e2b: { template: "oac-runtime:0f1e2d3c-4b5a-6978-8a9b-0c1d2e3f4a5b", api_url: "https://api.e2b.app", domain: "e2b.app", credential_configured: true, template_build: templateBuild } }; + return { ...configuredDeployment(), provider: "e2b", mode: "direct", rollout: noNodeRollout(), resources: { allocations: 3, pending: 1 }, specification: { resources: { cpus: 2, memory_mib: 2048 } }, configuration: { template: "oac-runtime:0f1e2d3c-4b5a-6978-8a9b-0c1d2e3f4a5b", api_url: "https://api.e2b.app", domain: "e2b.app" } , credential_configured: true, metadata: { template_build: templateBuild } }; } function reset(mode = "login", fresh = false, sandbox = "configured", nodes = "demo", address = "public", credentials = "configured", installers = true, artifacts = "docker,microsandbox") { @@ -331,16 +331,16 @@ function nodeDetail(node) { } async function sandboxRoute(request, response, path, url) { - const e2bTemplates = ["/e2b/templates", "/e2b/templates/template/builds"]; + const e2bTemplates = ["/providers/e2b/discovery"]; if (e2bTemplates.includes(path)) { if (request.method !== "POST") return error(response, 405, "Method not allowed."); const input = await body(request); const knownEndpoint = [ ["https://sandbox.sandbase.ai", "sandbox.sandbase.ai"], ["https://api.e2b.app", "e2b.app"], - ].some(([apiURL, domain]) => input.api_url === apiURL && input.domain === domain); - if (input.api_key !== "fixture-private-key" || !knownEndpoint) return error(response, 400, "Invalid E2B connection."); - if (path === "/e2b/templates") return send(response, 200, { templates: [{ id: "template", names: ["fixture-runtime"] }] }); + ].some(([apiURL, domain]) => input.configuration?.api_url === apiURL && input.configuration?.domain === domain); + if (input.credential?.api_key !== "fixture-private-key" || !knownEndpoint) return error(response, 400, "Invalid E2B connection."); + if (!input.query?.template) return send(response, 200, { templates: [{ id: "template", names: ["fixture-runtime"] }] }); return send(response, 200, { builds: [{ id: "94be54a1-138c-4f30-bc87-b13686272dbe", cpus: 2, memory_mib: 2048 }] }); } // Retired even for authenticated callers; never reinterpret maintenance as reset. @@ -393,16 +393,16 @@ async function sandboxRoute(request, response, path, url) { if (e2b && state.installation === "local") return error(response, 409, "E2B sandboxes reach Core over the internet. Set an HTTPS public URL that is not loopback (public_url in config.json, OAC_PUBLIC_URL for Core).", "sandbox_configuration_error"); // Synthetic classifier outcomes only; never persist or echo submitted keys. if (e2b) { - if (!input.e2b?.template || (initialize && !input.e2b.api_key) || (Object.hasOwn(input.e2b ?? {}, "api_key") && !input.e2b.api_key)) return error(response, 400, "The E2B API key was rejected.", "e2b_api_key_invalid"); - if (input.e2b.api_key === "fixture-other-team-key") return error(response, 409, "This E2B key cannot manage the retained deployment. Reset before changing teams.", "e2b_team_mismatch"); - if (input.e2b.api_key === "fixture-invalid-key") return error(response, 400, "The E2B API key was rejected.", "e2b_api_key_invalid"); + if (!input.configuration?.template || (initialize && !input.credential?.api_key) || (Object.hasOwn(input, "credential") && !input.credential?.api_key)) return error(response, 400, "The E2B API key was rejected.", "sandbox_credential_invalid"); + if (input.credential?.api_key === "fixture-other-team-key") return error(response, 409, "This E2B key cannot manage the retained deployment. Reset before changing teams.", "sandbox_credential_ownership"); + if (input.credential?.api_key === "fixture-invalid-key") return error(response, 400, "The E2B API key was rejected.", "sandbox_credential_invalid"); } // As Core: E2B may omit resources and adopt its template build's CPU and memory; only microsandbox suspends. const resources = input.resources ?? { cpus: templateBuild.resources.cpus, memory_mib: templateBuild.resources.memory_mib }; const previous = state.deployment; const specification = { resources, ...(input.runtime ? { runtime: input.runtime } : {}) }; - const explicitKey = e2b && Object.hasOwn(input.e2b, "api_key"); - const sameSelection = !initialize && JSON.stringify(specification) === JSON.stringify(previous.specification) && (!e2b || input.e2b.template === previous.e2b?.template); + const explicitKey = e2b && Object.hasOwn(input, "credential"); + const sameSelection = !initialize && JSON.stringify(specification) === JSON.stringify(previous.specification) && (!e2b || input.configuration.template === previous.configuration?.template); // Omission can be a no-op; every explicit key, including identical bytes, // takes the verified replacement path and advances the target generation. if (sameSelection && !explicitKey) return send(response, 200, previous); @@ -416,7 +416,7 @@ async function sandboxRoute(request, response, path, url) { resources: held, rollout: e2b ? { ...noNodeRollout(), previous_generation_sandboxes: held.allocations + held.pending } : nodeRollout(held.allocations + held.pending), specification, - ...(e2b ? { e2b: { template: input.e2b?.template ?? "", api_url: input.e2b?.api_url ?? state.deployment.e2b?.api_url ?? "https://api.e2b.app", domain: input.e2b?.domain ?? state.deployment.e2b?.domain ?? "e2b.app", credential_configured: true, template_build: templateBuild } } : {}), + ...(e2b ? { configuration: { template: input.configuration?.template ?? "", api_url: input.configuration?.api_url ?? state.deployment.configuration?.api_url ?? "https://api.e2b.app", domain: input.configuration?.domain ?? state.deployment.configuration?.domain ?? "e2b.app" } , credential_configured: true, metadata: { template_build: templateBuild } } : { configuration: {}, metadata: {}, credential_configured: false }), suspension: input.provider === "microsandbox" ? { idle_seconds: 300, retention_seconds: 86400 } : null, }; return send(response, 200, state.deployment); diff --git a/apps/web/e2e/nodes.spec.ts b/apps/web/e2e/nodes.spec.ts index 380df6b76..e99095753 100644 --- a/apps/web/e2e/nodes.spec.ts +++ b/apps/web/e2e/nodes.spec.ts @@ -261,13 +261,10 @@ test("saves E2B without opening Add node, as it has no machines", async ({ page, await page.getByRole("button", { name: "Save configuration" }).click(); await expect(page.getByRole("heading", { name: "Sandbox configuration", level: 1 })).toBeVisible(); await expect(page.getByRole("dialog")).toHaveCount(0); - expect(submitted).toMatchObject({ provider: "e2b", e2b: { - api_key: "fixture-private-key", template: "template:94be54a1-138c-4f30-bc87-b13686272dbe", - api_url: "https://sandbox.sandbase.ai", domain: "sandbox.sandbase.ai", - } }); + expect(submitted).toMatchObject({ provider: "e2b", configuration: { template: "template:94be54a1-138c-4f30-bc87-b13686272dbe", api_url: "https://sandbox.sandbase.ai", domain: "sandbox.sandbase.ai" } , credential: { api_key: "fixture-private-key" } }); expect(await writes(request)).toEqual([ - "POST /core/v1/sandbox/e2b/templates", - "POST /core/v1/sandbox/e2b/templates/template/builds", + "POST /core/v1/sandbox/providers/e2b/discovery", + "POST /core/v1/sandbox/providers/e2b/discovery", "POST /core/v1/sandbox/deployment", ]); }); diff --git a/apps/web/e2e/sandbox-generation.spec.ts b/apps/web/e2e/sandbox-generation.spec.ts index 7b6548de8..d91028905 100644 --- a/apps/web/e2e/sandbox-generation.spec.ts +++ b/apps/web/e2e/sandbox-generation.spec.ts @@ -181,15 +181,15 @@ test("E2B omitted-key updates keep the saved key while explicit same-key replace const initial = await deploymentRead(page); await editE2B(page); const omitted = await saveConfiguration(page); - expect(omitted.input).toMatchObject({ provider: "e2b", expected_generation: 1, e2b: { template: initial.e2b!.template } }); - expect(omitted.input.e2b).not.toHaveProperty("api_key"); + expect(omitted.input).toMatchObject({ provider: "e2b", expected_generation: 1, configuration: { template: initial.configuration!.template } }); + expect(omitted.input).not.toHaveProperty("credential"); expect((await omitted.response.json()).generation).toBe(1); await expect(page.getByRole("dialog", { name: "Change resources", exact: true })).toBeHidden(); const key = "fixture-same-team-key"; for (const generation of [1, 2]) { await editE2B(page, key); const explicit = await saveConfiguration(page); - expect(explicit.input).toMatchObject({ provider: "e2b", expected_generation: generation, e2b: { template: initial.e2b!.template, api_key: key } }); + expect(explicit.input).toMatchObject({ provider: "e2b", expected_generation: generation, configuration: { template: initial.configuration!.template } , credential: { api_key: key } }); const current = await explicit.response.json() as SandboxDeployment; expect(current.generation).toBe(generation + 1); expect(current.resources).toEqual(initial.resources); diff --git a/apps/web/src/features/fleet/SandboxResetNotice.test.tsx b/apps/web/src/features/fleet/SandboxResetNotice.test.tsx index 6ab8097f2..527b64cb6 100644 --- a/apps/web/src/features/fleet/SandboxResetNotice.test.tsx +++ b/apps/web/src/features/fleet/SandboxResetNotice.test.tsx @@ -6,7 +6,7 @@ import { SandboxResetNotice } from "./SandboxResetNotice"; const reset: SandboxReset = { clear: "auto", requested_at: "2026-09-27T10:00:00Z", deadline_at: "2026-09-27T11:00:00Z", forced_at: null, remaining: { busy: 1, idle: 0, cleanup: 2, on_offline_nodes: 1, offline_nodes: [{ node_id: "n1", name: "Node 1", resources: 1 }] } }; -const deployment = (reset: SandboxReset | null): SandboxDeployment => ({ rollout: { state: "settled", previous_generation_sandboxes: 0, nodes: { ready: 1, preparing: 0, failed: 0, update_required: 0, unknown: 0 } }, installation_id: "i", provider: "docker", core_url: "http://core", reset, owner_epoch: 1, generation: 1, mode: "nodes", resources: { allocations: 3, pending: 0 }, suspension: null }); +const deployment = (reset: SandboxReset | null): SandboxDeployment => ({ credential_configured: false, configuration: {}, metadata: {}, rollout: { state: "settled", previous_generation_sandboxes: 0, nodes: { ready: 1, preparing: 0, failed: 0, update_required: 0, unknown: 0 } }, installation_id: "i", provider: "docker", core_url: "http://core", reset, owner_epoch: 1, generation: 1, mode: "nodes", resources: { allocations: 3, pending: 0 }, suspension: null }); const render = (value: SandboxDeployment | undefined, failed = false) => renderToStaticMarkup( {}} />); describe("reset notices on read-only surfaces", () => { diff --git a/apps/web/src/features/fleet/fleet-queries.test.ts b/apps/web/src/features/fleet/fleet-queries.test.ts index 25b8342b6..05b3efded 100644 --- a/apps/web/src/features/fleet/fleet-queries.test.ts +++ b/apps/web/src/features/fleet/fleet-queries.test.ts @@ -5,7 +5,7 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import { sandboxAdmin, sandboxDeploymentQuery, sandboxScope } from "../sandbox/sandbox-queries"; import { fleetQuery } from "./fleet-queries"; -const deployment: SandboxDeployment = { rollout: { state: "settled", previous_generation_sandboxes: 0, nodes: { ready: 1, preparing: 0, failed: 0, update_required: 0, unknown: 0 } }, installation_id: "i", provider: "docker", core_url: "http://core", owner_epoch: 1, generation: 1, mode: "nodes", resources: { allocations: 1, pending: 0 }, suspension: null, +const deployment: SandboxDeployment = { credential_configured: false, configuration: {}, metadata: {}, rollout: { state: "settled", previous_generation_sandboxes: 0, nodes: { ready: 1, preparing: 0, failed: 0, update_required: 0, unknown: 0 } }, installation_id: "i", provider: "docker", core_url: "http://core", owner_epoch: 1, generation: 1, mode: "nodes", resources: { allocations: 1, pending: 0 }, suspension: null, reset: { clear: "auto", requested_at: "2026-09-27T10:00:00Z", deadline_at: "2026-09-27T11:00:00Z", forced_at: null, remaining: { busy: 1, idle: 0, cleanup: 0, on_offline_nodes: 0, offline_nodes: [] } } }; afterEach(() => { vi.restoreAllMocks(); vi.unstubAllGlobals(); }); diff --git a/apps/web/src/features/fleet/use-sandbox-fleet.test.tsx b/apps/web/src/features/fleet/use-sandbox-fleet.test.tsx index 928d5c408..4e01fa9be 100644 --- a/apps/web/src/features/fleet/use-sandbox-fleet.test.tsx +++ b/apps/web/src/features/fleet/use-sandbox-fleet.test.tsx @@ -11,7 +11,7 @@ import { consoleConfigQuery, fleetQuery, type FleetSnapshot } from "./fleet-quer import { FleetReadNotice } from "./FleetReadNotice"; import { fleetSnapshot, useSandboxFleet } from "./use-sandbox-fleet"; -const configured: SandboxDeployment = { rollout: { state: "settled", previous_generation_sandboxes: 0, nodes: { ready: 1, preparing: 0, failed: 0, update_required: 0, unknown: 0 } }, installation_id: "i", provider: "docker", core_url: "http://core", reset: null, owner_epoch: 1, generation: 1, mode: "nodes", resources: { allocations: 1, pending: 0 }, suspension: null }; +const configured: SandboxDeployment = { credential_configured: false, configuration: {}, metadata: {}, rollout: { state: "settled", previous_generation_sandboxes: 0, nodes: { ready: 1, preparing: 0, failed: 0, update_required: 0, unknown: 0 } }, installation_id: "i", provider: "docker", core_url: "http://core", reset: null, owner_epoch: 1, generation: 1, mode: "nodes", resources: { allocations: 1, pending: 0 }, suspension: null }; function Probe() { const { state, deployment } = useSandboxFleet(); diff --git a/apps/web/src/features/metrics/SandboxMetricsPage.tsx b/apps/web/src/features/metrics/SandboxMetricsPage.tsx index 8af46433b..75579fa12 100644 --- a/apps/web/src/features/metrics/SandboxMetricsPage.tsx +++ b/apps/web/src/features/metrics/SandboxMetricsPage.tsx @@ -234,10 +234,10 @@ function CloudSection({ deployment }: { deployment: SandboxDeployment }) { const { navigate } = useConsoleNavigation(); // An E2B selection may adopt its template build's size instead of saving one. const resources = sandboxSize(deployment); - const build = deployment.e2b?.template_build; + const build = deployment.metadata?.template_build; const disk = build?.resources.root_disk_mib ?? null; const status = templateBuildStatus(build); - const template = deployment.e2b?.template; + const template = deployment.configuration?.template; return (
): string { function cloudHost(fleet: FleetSnapshot | null): CloudHost | null { if (fleet?.deployment.provider !== "e2b") return null; - return { running: fleet.deployment.resources.allocations, pending: fleet.deployment.resources.pending, template: fleet.deployment.e2b?.template || null }; + return { running: fleet.deployment.resources.allocations, pending: fleet.deployment.resources.pending, template: fleet.deployment.configuration?.template || null }; } /** Core and its sandbox nodes (or E2B's cloud) as a topology; each opens a popover with the way onward. */ diff --git a/apps/web/src/features/overview/getting-started.test.ts b/apps/web/src/features/overview/getting-started.test.ts index 4f31b385d..ec662c38b 100644 --- a/apps/web/src/features/overview/getting-started.test.ts +++ b/apps/web/src/features/overview/getting-started.test.ts @@ -5,7 +5,7 @@ import type { FleetState } from "../fleet/use-sandbox-fleet"; import { checklistStorageKey, checklistView, gettingStartedSteps, nextStepAfterNode, rememberInstallation } from "./getting-started"; import { node, project } from "./test-fixtures"; -const deployment = (overrides: Partial = {}): SandboxDeployment => ({ +const deployment = (overrides: Partial = {}): SandboxDeployment => ({ credential_configured: false, configuration: {}, metadata: {}, rollout: { state: "settled", previous_generation_sandboxes: 0, nodes: { ready: 1, preparing: 0, failed: 0, update_required: 0, unknown: 0 } }, installation_id: "i", provider: "docker", core_url: "http://core", reset: null, owner_epoch: 1, generation: 1, mode: "nodes", resources: { allocations: 0, pending: 0 }, suspension: null, ...overrides, }); @@ -63,7 +63,7 @@ describe("Getting started steps", () => { expect(sandboxes(fleet(deployment(), []))).toMatchObject({ state: "todo", action: "add-node" }); expect(sandboxes(fleet(deployment(), [node("n1", { provider_ready: false }), node("n2", { online: false })]))).toMatchObject({ state: "todo", action: "nodes" }); expect(sandboxes(fleet(deployment()))).toMatchObject({ state: "done" }); - const e2b = (status: string | null) => deployment({ provider: "e2b", mode: "direct", e2b: { template: "t", api_url: "https://api.e2b.app", domain: "e2b.app", credential_configured: true, template_build: { status, resources: { cpus: 2, memory_mib: 2048, root_disk_mib: null } } } }); + const e2b = (status: string | null) => deployment({ provider: "e2b", mode: "direct", configuration: { template: "t", api_url: "https://api.e2b.app", domain: "e2b.app" } , credential_configured: true, metadata: { template_build: { status, resources: { cpus: 2, memory_mib: 2048, root_disk_mib: null } } } }); expect(sandboxes(fleet(e2b("building"), []))).toMatchObject({ state: "todo", cloud: true }); expect(sandboxes(fleet(e2b("ready"), []))).toMatchObject({ state: "done", cloud: true }); // Saved before Core recorded the build: Core admitted it, so it counts as ready. diff --git a/apps/web/src/features/overview/getting-started.ts b/apps/web/src/features/overview/getting-started.ts index 0fa930fb5..b95fbfb72 100644 --- a/apps/web/src/features/overview/getting-started.ts +++ b/apps/web/src/features/overview/getting-started.ts @@ -69,7 +69,7 @@ function sandboxStep(fleet: FleetState): GettingStartedSteps["sandboxes"] { const { deployment, nodes } = fleet.snapshot; if (!deployment.provider) return { state: "todo", action: "setup", cloud: false }; if (deployment.provider === "e2b") { - return { state: templateBuildStatus(deployment.e2b?.template_build) === "notReady" ? "todo" : "done", action: "nodes", cloud: true }; + return { state: templateBuildStatus(deployment.metadata?.template_build) === "notReady" ? "todo" : "done", action: "nodes", cloud: true }; } if (nodes.some(nodeServingReady)) return { state: "done", action: "nodes", cloud: false }; return { state: "todo", action: nodes.length ? "nodes" : "add-node", cloud: false }; diff --git a/apps/web/src/features/sandbox/SandboxDeploymentSettings.tsx b/apps/web/src/features/sandbox/SandboxDeploymentSettings.tsx index e7e78e873..df2c0430e 100644 --- a/apps/web/src/features/sandbox/SandboxDeploymentSettings.tsx +++ b/apps/web/src/features/sandbox/SandboxDeploymentSettings.tsx @@ -34,7 +34,7 @@ export function SandboxDeploymentSettings({ deployment, disabled, fresh, onReset const spec = deployment.specification; // An E2B selection may adopt its template build's size instead of saving one. const size = sandboxSize(deployment); - const build = deployment.e2b?.template_build; + const build = deployment.metadata?.template_build; const buildSize = templateBuildSize(deployment); const sizeLabel = (value: { cpus: number; memory_mib: number }) => t("{{cpus}} CPU · {{memory}}", { cpus: value.cpus, memory: formatBytes(value.memory_mib * MIB) }); const canEdit = fresh && !deployment.reset; @@ -58,17 +58,17 @@ export function SandboxDeploymentSettings({ deployment, disabled, fresh, onReset {deployment.provider === "e2b" ?
-
{t("Sandbox API URL")}
{deployment.e2b?.api_url}
-
{t("Sandbox data-plane domain")}
{deployment.e2b?.domain}
+
{t("Sandbox API URL")}
{deployment.configuration?.api_url}
+
{t("Sandbox data-plane domain")}
{deployment.configuration?.domain}
-
{t("Template build")}{deployment.e2b?.template || t("Unknown state")}
+
{t("Template build")}{deployment.configuration?.template || t("Unknown state")}
{[ t(buildStatusLabel[templateBuildStatus(build)]), ...(buildSize ? [sizeLabel(buildSize)] : []), ...(build?.resources.root_disk_mib != null ? [t("{{disk}} disk", { disk: formatBytes(build.resources.root_disk_mib * MIB) })] : []), ].join(" · ")}
-
{t("E2B credential")}
{t(deployment.e2b?.credential_configured ? "Configured" : "Not configured")}
+
{t("E2B credential")}
{t(deployment.credential_configured ? "Configured" : "Not configured")}
: null} @@ -83,7 +83,7 @@ export function SandboxDeploymentSettings({ deployment, disabled, fresh, onReset key={editKey} coreUrl={deployment.core_url} expectedGeneration={deployment.generation} - current={deployment.provider ? { provider: deployment.provider, specification: deployment.specification, e2bTemplate: deployment.e2b?.template, e2bAPIURL: deployment.e2b?.api_url, e2bDomain: deployment.e2b?.domain } : undefined} + current={deployment.provider ? { provider: deployment.provider, specification: deployment.specification, e2bTemplate: deployment.configuration?.template, e2bAPIURL: deployment.configuration?.api_url, e2bDomain: deployment.configuration?.domain } : undefined} disabled={disabled || !canEdit} editing onSubmit={async (input) => { if (await onUpdate(input)) setChanging(false); }} diff --git a/apps/web/src/features/sandbox/SandboxResetControls.test.tsx b/apps/web/src/features/sandbox/SandboxResetControls.test.tsx index 128253eeb..840802374 100644 --- a/apps/web/src/features/sandbox/SandboxResetControls.test.tsx +++ b/apps/web/src/features/sandbox/SandboxResetControls.test.tsx @@ -5,7 +5,7 @@ import { SandboxResetControls } from "./SandboxResetControls"; const reset: SandboxReset = { clear: "auto", requested_at: "2020-01-01T10:00:00Z", deadline_at: "2020-01-01T11:00:00Z", forced_at: null, remaining: { busy: 2, idle: 1, cleanup: 3, on_offline_nodes: 2, offline_nodes: [{ node_id: "offline-a", name: "Offline A", resources: 2 }] } }; -const deployment = (value: SandboxReset | null): SandboxDeployment => ({ rollout: { state: "settled", previous_generation_sandboxes: 0, nodes: { ready: 1, preparing: 0, failed: 0, update_required: 0, unknown: 0 } }, installation_id: "i", provider: "docker", core_url: "https://core.example", reset: value, owner_epoch: 1, generation: 1, mode: "nodes", resources: { allocations: 5, pending: 1 }, suspension: null }); +const deployment = (value: SandboxReset | null): SandboxDeployment => ({ credential_configured: false, configuration: {}, metadata: {}, rollout: { state: "settled", previous_generation_sandboxes: 0, nodes: { ready: 1, preparing: 0, failed: 0, update_required: 0, unknown: 0 } }, installation_id: "i", provider: "docker", core_url: "https://core.example", reset: value, owner_epoch: 1, generation: 1, mode: "nodes", resources: { allocations: 5, pending: 1 }, suspension: null }); const render = (value: SandboxReset | null, stale = false) => renderToStaticMarkup( true} onCancel={async () => true} />); describe("authoritative reset progress", () => { diff --git a/apps/web/src/features/sandbox/SandboxRolloutSummary.test.tsx b/apps/web/src/features/sandbox/SandboxRolloutSummary.test.tsx index 45751c532..b61ba57ed 100644 --- a/apps/web/src/features/sandbox/SandboxRolloutSummary.test.tsx +++ b/apps/web/src/features/sandbox/SandboxRolloutSummary.test.tsx @@ -6,7 +6,7 @@ import { SandboxRolloutSummary } from "./SandboxRolloutSummary"; import { NodeRolloutStatus } from "./NodeRolloutStatus"; import { SandboxDeploymentSettings } from "./SandboxDeploymentSettings"; -const deployment: SandboxDeployment = { installation_id: "i", owner_epoch: 1, generation: 4, provider: "docker", mode: "nodes", core_url: "https://core.example", resources: { allocations: 8, pending: 2 }, reset: null, suspension: null, +const deployment: SandboxDeployment = { credential_configured: false, configuration: {}, metadata: {}, installation_id: "i", owner_epoch: 1, generation: 4, provider: "docker", mode: "nodes", core_url: "https://core.example", resources: { allocations: 8, pending: 2 }, reset: null, suspension: null, rollout: { state: "settled", previous_generation_sandboxes: 8, nodes: { ready: 1, preparing: 0, failed: 2, update_required: 3, unknown: 4 } } }; describe("authoritative configuration rollout", () => { diff --git a/apps/web/src/features/sandbox/SandboxSetupWizard.tsx b/apps/web/src/features/sandbox/SandboxSetupWizard.tsx index a7b3cdeb8..738dfe413 100644 --- a/apps/web/src/features/sandbox/SandboxSetupWizard.tsx +++ b/apps/web/src/features/sandbox/SandboxSetupWizard.tsx @@ -233,8 +233,8 @@ export function SandboxSetupWizard({ coreUrl, expectedGeneration, current, disab ...(sized ? { resources } : {}), ...(needsRuntime ? { runtime: release as SandboxRuntimeRelease } : {}), }; - if (editing) await onSubmit({ ...selection, ...(provider === "e2b" ? { e2b: { ...e2bUpdateSelection(template, apiKey), api_url: apiURL.trim(), domain: domain.trim() } } : {}) }); - else await onSubmit({ ...selection, ...(provider === "e2b" ? { e2b: { api_key: apiKey.trim(), template: template.trim(), api_url: apiURL.trim(), domain: domain.trim() } } : {}) }); + if (editing) await onSubmit({ ...selection, ...(provider === "e2b" ? { ...e2bUpdateSelection(template, apiKey), configuration: { template: template.trim(), api_url: apiURL.trim(), domain: domain.trim() } } : {}) }); + else await onSubmit({ ...selection, ...(provider === "e2b" ? { credential: { api_key: apiKey.trim() }, configuration: { template: template.trim(), api_url: apiURL.trim(), domain: domain.trim() } } : {}) }); } catch (error) { // A configuration Core rejected is explained here; the page reports every other failure. const reason = sandboxConfigurationRejection(error, i18n.resolvedLanguage?.startsWith("zh") ? "zh" : "en"); @@ -242,10 +242,10 @@ export function SandboxSetupWizard({ coreUrl, expectedGeneration, current, disab setRejection(reason); setFieldRejection(error); if (error instanceof AgentCoreError && error.param) { - if (["e2b.api_key", "e2b.template", "e2b.api_url", "e2b.domain"].includes(error.param) && error.code !== "e2b_team_mismatch") setStep("e2b"); + if (["credential", "configuration", "e2b.api_url", "e2b.domain"].includes(error.param) && error.code !== "sandbox_credential_ownership") setStep("e2b"); else if (error.param === "runtime" || error.param.startsWith("resources.")) setStep("advanced"); } - setResetRequired(error instanceof AgentCoreError && ["e2b_team_mismatch", "sandbox_reset_required"].includes(error.code ?? "")); + setResetRequired(error instanceof AgentCoreError && ["sandbox_credential_ownership", "sandbox_reset_required"].includes(error.code ?? "")); setAddressRejected(error instanceof AgentCoreError && error.code === "sandbox_configuration_error"); } finally { setApiKey(""); @@ -292,7 +292,7 @@ export function SandboxSetupWizard({ coreUrl, expectedGeneration, current, disab - {t("Console → API Keys")}