diff --git a/docs/sandbox-provider.md b/docs/sandbox-provider.md index 32c51eccd..4f781c39e 100644 --- a/docs/sandbox-provider.md +++ b/docs/sandbox-provider.md @@ -115,6 +115,8 @@ A new provider takes these steps: **Known design gap:** the installer's `--sandbox` choices and Web's setup views carry provider-specific options, such as E2B's installer flags and Web views. Exposing another provider through these surfaces currently requires shared installer and Web edits. This coupling does not meet [Complexity stays in the adapter](../AGENTS.md#complexity-stays-in-the-adapter); new integrations must express their configuration through the protocol and keep vendor-specific behavior in the adapter. Never add a Session or Turn scheduling path, a vendor column or API field, or a vendor switch in the store. +`providers.Build` passes persisted node configuration and ephemeral `LocalOptions` to `BuildLocal`. The caller explicitly selects standalone registration or single-provider execution with `Standalone`, or generation-owned execution with a canonical absolute node state directory in `GenerationStateDirectory`. Missing or mixed contexts are rejected. The adapter owns generation-specific native preparation and readiness checks. Microsandbox binds helper leases to the installation, generation and specification digest, then checks the pinned image after platform, capacity and artifact readiness. + ### Registration validation `providers.ValidateRegistration` is the single wiring check. Lookup, constructor binding and the installer projection run it before any configuration callback or constructor. An unknown provider name stays invalid input; a malformed registration returns a safe `providercontract.ErrContract` that includes no submitted configuration or native diagnostics. diff --git a/services/core/cmd/sandbox-node/generations.go b/services/core/cmd/sandbox-node/generations.go index 27fdd53e8..095c4fe3a 100644 --- a/services/core/cmd/sandbox-node/generations.go +++ b/services/core/cmd/sandbox-node/generations.go @@ -167,50 +167,11 @@ func runGenerations(ctx context.Context, configFile, stateDir string) error { } func buildGeneration(config providerconfig.Config, stateDir string) (node.GenerationProvider, error) { - if config.Microsandbox != nil { - directory := filepath.Join(stateDir, "generations") - if err := os.MkdirAll(directory, 0700); err != nil { - return node.GenerationProvider{}, err - } - info, err := os.Lstat(directory) - if err != nil || !info.IsDir() || info.Mode().Perm() != 0700 { - return node.GenerationProvider{}, sandbox.ErrOwnership - } - config.Microsandbox.HelperLeaseGeneration = config.Generation - config.Microsandbox.HelperLeasePath = filepath.Join(directory, strconv.FormatUint(config.Generation, 10)+".lease") - } - built, closeProvider, err := providerconfig.Build(config) + built, closeProvider, err := providerconfig.Build(config, providerconfig.LocalOptions{GenerationStateDirectory: stateDir}) if err != nil { return node.GenerationProvider{}, err } - probe := built.Probe - if micro := config.Microsandbox; micro != nil { - probe = func(ctx context.Context) error { - if err := built.Probe(ctx); err != nil { - return err - } - command := exec.CommandContext(ctx, micro.RuntimePath, "image", "inspect", micro.Image, "--format", "json") - command.Env = append([]string{"PATH=/usr/local/bin:/usr/bin:/bin", "HOME=" + os.Getenv("HOME")}, "MSB_BACKEND=local", "MSB_HOME="+micro.RuntimeHome, "MSB_PATH="+micro.RuntimePath, "MSB_LIBKRUNFW_PATH="+micro.FirmwarePath) - reader, err := command.StdoutPipe() - if err != nil { - return sandbox.ErrRuntimeImageUnavailable - } - if err := command.Start(); err != nil { - return sandbox.ErrRuntimeImageUnavailable - } - raw, readErr := io.ReadAll(io.LimitReader(reader, 64*1024+1)) - if len(raw) > 64*1024 { - _ = command.Process.Kill() - } - waitErr := command.Wait() - var image struct{ Digest, Architecture, OS string } - if readErr != nil || waitErr != nil || len(raw) > 64*1024 || json.Unmarshal(raw, &image) != nil || image.Digest != strings.SplitN(micro.Image, "@", 2)[1] || image.Architecture != "amd64" || image.OS != "linux" { - return sandbox.ErrRuntimeImageUnavailable - } - return nil - } - } - return node.GenerationProvider{Generation: config.Generation, SpecificationDigest: built.SpecificationDigest, Provider: built.Provider, Probe: probe, Close: closeProvider}, nil + return node.GenerationProvider{Generation: config.Generation, SpecificationDigest: built.SpecificationDigest, Provider: built.Provider, Probe: built.Probe, Close: closeProvider}, nil } type generationJournal struct { diff --git a/services/core/cmd/sandbox-node/generations_test.go b/services/core/cmd/sandbox-node/generations_test.go index daeb9b83c..56a015270 100644 --- a/services/core/cmd/sandbox-node/generations_test.go +++ b/services/core/cmd/sandbox-node/generations_test.go @@ -143,3 +143,37 @@ func TestUnresolvedPreparationRemainsRecoveryOnly(t *testing.T) { } } } + +func TestGenerationStateDirectoryOwnership(t *testing.T) { + for _, mode := range []string{"private", "public", "symlink", "symlink_parent"} { + t.Run(mode, func(t *testing.T) { + stateDir := t.TempDir() + directory := filepath.Join(stateDir, "generations") + if mode == "symlink" { + if err := os.Symlink(t.TempDir(), directory); err != nil { + t.Fatal(err) + } + } else { + if err := os.Mkdir(directory, 0700); err != nil { + t.Fatal(err) + } + } + if mode == "public" { + if err := os.Chmod(directory, 0755); err != nil { + t.Fatal(err) + } + } + if mode == "symlink_parent" { + link := filepath.Join(t.TempDir(), "node") + if err := os.Symlink(stateDir, link); err != nil { + t.Fatal(err) + } + stateDir = link + } + _, err := generationLocalState(providerconfig.Config{Generation: 1}, stateDir) + if mode == "private" && err != nil || mode != "private" && !errors.Is(err, sandbox.ErrOwnership) { + t.Fatalf("directory ownership: %v", err) + } + }) + } +} diff --git a/services/core/cmd/sandbox-node/main.go b/services/core/cmd/sandbox-node/main.go index d6bfdedac..d947f8d92 100644 --- a/services/core/cmd/sandbox-node/main.go +++ b/services/core/cmd/sandbox-node/main.go @@ -80,7 +80,7 @@ func run(ctx context.Context, args []string) error { if err != nil { return err } - built, closeProvider, err := providerconfig.Build(config) + built, closeProvider, err := providerconfig.Build(config, providerconfig.LocalOptions{Standalone: true}) if err != nil { return err } diff --git a/services/core/internal/sandbox/providers/config.go b/services/core/internal/sandbox/providers/config.go index 51f2b11d6..2a0a6701c 100644 --- a/services/core/internal/sandbox/providers/config.go +++ b/services/core/internal/sandbox/providers/config.go @@ -10,6 +10,7 @@ import ( "errors" "io" "os" + "path/filepath" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/sandbox" "github.com/google/uuid" @@ -65,7 +66,17 @@ type Built struct { Probe func(context.Context) error } -func Build(config Config) (*Built, func(), error) { +// LocalOptions supplies process-local context without changing persisted configuration. +type LocalOptions struct { + // Standalone selects registration or execution without a generation manager. + Standalone bool + // GenerationStateDirectory is the node state directory when constructing a + // retained generation. It must be canonical and absolute, and Standalone + // must be false. + GenerationStateDirectory string +} + +func Build(config Config, options LocalOptions) (*Built, func(), error) { closeProvider := func() {} if err := validateSpecification(config); err != nil { return nil, closeProvider, err @@ -78,8 +89,15 @@ func Build(config Config) (*Built, func(), error) { if err != nil || adapter.BuildLocal == nil { return nil, closeProvider, errors.New("sandbox provider is not node-local") } + if options.Standalone { + if options.GenerationStateDirectory != "" { + return nil, closeProvider, sandbox.ErrInvalid + } + } else if !filepath.IsAbs(options.GenerationStateDirectory) || filepath.Clean(options.GenerationStateDirectory) != options.GenerationStateDirectory { + return nil, closeProvider, sandbox.ErrInvalid + } result := &Built{InstallationID: config.InstallationID, SpecificationDigest: config.Specification.Digest(config.Provider)} - closeProvider, err = adapter.BuildLocal(config, result) + closeProvider, err = adapter.BuildLocal(config, options, result) if err != nil { return nil, closeProvider, err } diff --git a/services/core/internal/sandbox/providers/docker.go b/services/core/internal/sandbox/providers/docker.go index 49512ed40..c0f186c00 100644 --- a/services/core/internal/sandbox/providers/docker.go +++ b/services/core/internal/sandbox/providers/docker.go @@ -12,7 +12,7 @@ import ( "github.com/moby/moby/client" ) -func buildDocker(config Config, result *Built) (func(), error) { +func buildDocker(config Config, _ LocalOptions, result *Built) (func(), error) { closeProvider := func() {} if config.Docker == nil || config.Microsandbox != nil { return closeProvider, errors.New("managed Docker requires only the docker configuration object") diff --git a/services/core/internal/sandbox/providers/generation_probe_test.go b/services/core/internal/sandbox/providers/generation_probe_test.go new file mode 100644 index 000000000..83e9dfe28 --- /dev/null +++ b/services/core/internal/sandbox/providers/generation_probe_test.go @@ -0,0 +1,82 @@ +package providers + +import ( + "context" + "errors" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/sandbox" +) + +func TestMicrosandboxGenerationImageProbe(t *testing.T) { + dir := t.TempDir() + imageDigest := "sha256:" + strings.Repeat("d", 64) + entry := Microsandbox{RuntimePath: filepath.Join(dir, "msb"), RuntimeHome: dir, FirmwarePath: filepath.Join(dir, "firmware"), Image: "oac-runtime@" + imageDigest} + // Check the native argument/environment boundary, including ambient isolation. + script := `#!/bin/sh +[ "$#" = 5 ] && [ "$1" = image ] && [ "$2" = inspect ] && [ "$3" = 'oac-runtime@` + imageDigest + `' ] && [ "$4" = --format ] && [ "$5" = json ] || exit 1 +[ "$MSB_BACKEND" = local ] && [ "$MSB_PATH" = "$0" ] && [ "$MSB_LIBKRUNFW_PATH" = "$MSB_HOME/firmware" ] || exit 2 +[ "$PATH" = /usr/local/bin:/usr/bin:/bin ] && [ -n "$HOME" ] && [ -z "${OAC_TEST_UNEXPECTED+x}" ] || exit 3 +cat "$MSB_HOME/output" +` + if err := os.WriteFile(entry.RuntimePath, []byte(script), 0700); err != nil { + t.Fatal(err) + } + t.Setenv("OAC_TEST_UNEXPECTED", "must not reach native command") + t.Setenv("MSB_BACKEND", "ambient-backend") + t.Setenv("HOME", dir) + calls := 0 + probe := microsandboxGenerationProbe(entry, func(context.Context) error { calls++; return nil }) + valid := `{"Digest":"` + imageDigest + `","Architecture":"amd64","OS":"linux"}` + for _, tc := range []struct { + name, output string + valid bool + }{ + {"ready", valid, true}, + {"invalid_json", `{`, false}, + {"trailing_json", valid + `{}`, false}, + {"wrong_digest", strings.Replace(valid, imageDigest, "sha256:"+strings.Repeat("e", 64), 1), false}, + {"wrong_architecture", strings.Replace(valid, "amd64", "arm64", 1), false}, + {"wrong_os", strings.Replace(valid, "linux", "darwin", 1), false}, + {"missing_digest", `{"Architecture":"amd64","OS":"linux"}`, false}, + {"oversize", valid + strings.Repeat(" ", 64*1024), false}, + {"recovered", valid, true}, + } { + t.Run(tc.name, func(t *testing.T) { + if err := os.WriteFile(filepath.Join(dir, "output"), []byte(tc.output), 0600); err != nil { + t.Fatal(err) + } + err := probe(t.Context()) + if tc.valid && err != nil || !tc.valid && !errors.Is(err, sandbox.ErrRuntimeImageUnavailable) { + t.Fatalf("image result = %v", err) + } + }) + } + if calls != 9 { + t.Fatalf("base probe calls = %d", calls) + } + if err := os.Remove(filepath.Join(dir, "output")); err != nil { + t.Fatal(err) + } + if err := probe(t.Context()); !errors.Is(err, sandbox.ErrRuntimeImageUnavailable) { + t.Fatalf("command failure = %v", err) + } + if err := os.Remove(entry.RuntimePath); err != nil { + t.Fatal(err) + } + if err := probe(t.Context()); !errors.Is(err, sandbox.ErrRuntimeImageUnavailable) { + t.Fatalf("start failure = %v", err) + } +} + +func TestMicrosandboxGenerationProbeRetainsEarlierFailures(t *testing.T) { + for _, failure := range []error{context.Canceled, sandbox.ErrKVMUnavailable, sandbox.ErrCapacityInsufficient, sandbox.ErrMicrosandboxArtifactsUnavailable, errors.New("microsandbox state directory is unavailable")} { + probe := microsandboxGenerationProbe(Microsandbox{RuntimePath: "/missing"}, func(context.Context) error { return failure }) + if got := probe(t.Context()); got != failure { + t.Fatalf("earlier failure changed: got %v, want %v", got, failure) + } + } +} diff --git a/services/core/internal/sandbox/providers/generation_test.go b/services/core/internal/sandbox/providers/generation_test.go new file mode 100644 index 000000000..35a1b7a72 --- /dev/null +++ b/services/core/internal/sandbox/providers/generation_test.go @@ -0,0 +1,213 @@ +package providers + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "errors" + "os" + "path/filepath" + "runtime" + "strconv" + "syscall" + "testing" + "time" + + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/sandbox" + sandboxmicro "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/sandbox/microsandbox" + "github.com/google/uuid" +) + +func generationConfig(t *testing.T) Config { + t.Helper() + dir := t.TempDir() + spec := validRegistrationSpec() + spec.Resources.RootDiskMiB, spec.Resources.EnvironmentDiskMiB = 8192, 8192 + return Config{Provider: "microsandbox", InstallationID: uuid.NewString(), Generation: 9, Specification: spec, + Microsandbox: &Microsandbox{HelperPath: filepath.Join(dir, "helper"), RuntimeHome: dir, RuntimePath: filepath.Join(dir, "msb"), FirmwarePath: filepath.Join(dir, "firmware"), + RuntimeSHA256: spec.Runtime.RuntimeSHA256, FirmwareSHA256: spec.Runtime.FirmwareSHA256, Image: spec.Runtime.MicrosandboxRef, + CPUs: uint8(spec.Resources.CPUs), MemoryMiB: spec.Resources.MemoryMiB, RootDiskMiB: spec.Resources.RootDiskMiB, EnvironmentDiskMiB: spec.Resources.EnvironmentDiskMiB, + Network: Network{DefaultEgress: "allow", DefaultIngress: "deny"}}} +} + +func TestMicrosandboxGenerationDirectoryOwnership(t *testing.T) { + for _, mode := range []string{"private", "public", "symlink", "file"} { + t.Run(mode, func(t *testing.T) { + state := t.TempDir() + directory := filepath.Join(state, "generations") + switch mode { + case "public": + if err := os.Mkdir(directory, 0755); err != nil { + t.Fatal(err) + } + if err := os.Chmod(directory, 0755); err != nil { + t.Fatal(err) + } + case "symlink": + if err := os.Symlink(t.TempDir(), directory); err != nil { + t.Fatal(err) + } + case "file": + if err := os.WriteFile(directory, nil, 0600); err != nil { + t.Fatal(err) + } + } + built, closeProvider, err := Build(generationConfig(t), LocalOptions{GenerationStateDirectory: state}) + closeProvider() + if mode == "private" { + info, statErr := os.Lstat(directory) + if err != nil || built == nil || statErr != nil || info.Mode().Perm() != 0700 { + t.Fatalf("private generation: %v, %v", err, statErr) + } + } else if err == nil || built != nil { + t.Fatalf("accepted unsafe directory: %v", err) + } else if mode != "file" && !errors.Is(err, sandbox.ErrOwnership) { + t.Fatalf("ownership error changed: %v", err) + } + }) + } +} + +// Exercise the actual ProcessCaller boundary to prove the constructor binds the +// lease to this generation, installation and specification before any helper runs. +func TestMicrosandboxGenerationBindsLeaseIdentity(t *testing.T) { + config := generationConfig(t) + state := t.TempDir() + built, closeProvider, err := Build(config, LocalOptions{GenerationStateDirectory: state}) + if err != nil { + t.Fatal(err) + } + defer closeProvider() + response, err := json.Marshal(sandboxmicro.Response{Version: sandboxmicro.ProtocolVersion}) + if err != nil { + t.Fatal(err) + } + script := "#!/bin/sh\n[ \"$OAC_NODE_GENERATION_LEASE_FD\" = 3 ] || exit 1\ncat >/dev/null\nprintf '%s' '" + string(response) + "'\n" + if err := os.WriteFile(config.Microsandbox.HelperPath, []byte(script), 0700); err != nil { + t.Fatal(err) + } + base := filepath.Join(state, "generations", strconv.FormatUint(config.Generation, 10)) + leasePath := base + ".lease" + ctx, cancel := context.WithTimeout(t.Context(), 5*time.Second) + defer cancel() + ref := sandbox.Reference{TenantID: uuid.NewString(), EnvironmentID: uuid.NewString(), AllocationID: uuid.NewString()} + if err := built.Provider.Kill(ctx, ref); !errors.Is(err, sandbox.ErrComputeUnconfirmed) { + t.Fatalf("missing lease accepted: %v", err) + } + if err := os.WriteFile(leasePath, nil, 0600); err != nil { + t.Fatal(err) + } + info, err := os.Stat(leasePath) + if err != nil { + t.Fatal(err) + } + st := info.Sys().(*syscall.Stat_t) + record := map[string]any{"installation_id": config.InstallationID, "generation": config.Generation, "specification_digest": built.SpecificationDigest, "device": uint64(st.Dev), "inode": st.Ino} + writeJSON := func(path string, value any) { + t.Helper() + raw, err := json.Marshal(value) + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(path, raw, 0600); err != nil { + t.Fatal(err) + } + } + writeJSON(base+".json", config) + for _, field := range []string{"installation_id", "generation", "specification_digest", "inode"} { + previous := record[field] + record[field] = "foreign" + writeJSON(base+".lease-identity", record) + if err := built.Provider.Kill(ctx, ref); !errors.Is(err, sandbox.ErrComputeUnconfirmed) { + t.Fatalf("accepted mismatched %s: %v", field, err) + } + record[field] = previous + } + writeJSON(base+".lease-identity", record) + if err := built.Provider.Kill(ctx, ref); err != nil { + t.Fatalf("owned lease failed: %v", err) + } + // Closing a provider never removes durable generation ownership records. + closeProvider() + for _, suffix := range []string{".lease", ".lease-identity", ".json"} { + if _, err := os.Stat(base + suffix); err != nil { + t.Fatal(err) + } + } + if err := os.Chmod(leasePath, 0644); err != nil { + t.Fatal(err) + } + if err := built.Provider.Kill(ctx, ref); !errors.Is(err, sandbox.ErrComputeUnconfirmed) { + t.Fatalf("accepted public lease: %v", err) + } +} + +func TestMicrosandboxGenerationRejectsUnpinnedImage(t *testing.T) { + for _, image := range []string{"latest", "oac-runtime@sha256:bad", "oac-runtime@sha256:"} { + t.Run(image, func(t *testing.T) { + config := generationConfig(t) + config.Microsandbox.Image = image + config.Specification.Runtime.MicrosandboxRef = image + built, closeProvider, err := Build(config, LocalOptions{GenerationStateDirectory: t.TempDir()}) + closeProvider() + if err == nil || built != nil { + t.Fatalf("accepted image %q", image) + } + }) + } +} + +func TestLocalConstructionRequiresExplicitContext(t *testing.T) { + state := t.TempDir() + for _, options := range []LocalOptions{ + {}, + {Standalone: true, GenerationStateDirectory: state}, + {GenerationStateDirectory: "relative"}, + {GenerationStateDirectory: state + "/../node"}, + } { + built, closeProvider, err := Build(generationConfig(t), options) + closeProvider() + if !errors.Is(err, sandbox.ErrInvalid) || built != nil { + t.Fatalf("accepted construction context %+v: %v", options, err) + } + } + built, closeProvider, err := Build(generationConfig(t), LocalOptions{Standalone: true}) + closeProvider() + if err != nil || built == nil { + t.Fatalf("standalone construction: %v", err) + } +} + +func TestMicrosandboxConstructionSelectsGenerationReadiness(t *testing.T) { + if runtime.GOOS != "linux" { + t.Skip("microsandbox requires Linux") + } + useKVM(t, true) + config := generationConfig(t) + script := []byte("#!/bin/sh\nprintf '%s' '{}'\n") + digest := sha256.Sum256(script) + config.Microsandbox.RuntimeSHA256 = hex.EncodeToString(digest[:]) + config.Microsandbox.FirmwareSHA256 = hex.EncodeToString(digest[:]) + config.Specification.Runtime.RuntimeSHA256 = config.Microsandbox.RuntimeSHA256 + config.Specification.Runtime.FirmwareSHA256 = config.Microsandbox.FirmwareSHA256 + for _, path := range []string{config.Microsandbox.RuntimePath, config.Microsandbox.FirmwarePath, config.Microsandbox.HelperPath} { + if err := os.WriteFile(path, script, 0700); err != nil { + t.Fatal(err) + } + } + if err := os.Chmod(config.Microsandbox.RuntimeHome, 0700); err != nil { + t.Fatal(err) + } + for _, options := range []LocalOptions{{Standalone: true}, {GenerationStateDirectory: t.TempDir()}} { + built, closeProvider, err := Build(config, options) + if err != nil { + t.Fatal(err) + } + err = built.Probe(t.Context()) + closeProvider() + if options.Standalone && err != nil || !options.Standalone && !errors.Is(err, sandbox.ErrRuntimeImageUnavailable) { + t.Fatalf("readiness for %+v: %v", options, err) + } + } +} diff --git a/services/core/internal/sandbox/providers/microsandbox.go b/services/core/internal/sandbox/providers/microsandbox.go index cf393379f..06dc7c3a4 100644 --- a/services/core/internal/sandbox/providers/microsandbox.go +++ b/services/core/internal/sandbox/providers/microsandbox.go @@ -2,7 +2,9 @@ package providers import ( "errors" + "os" "path/filepath" + "strconv" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/sandbox" sandboxmicro "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/sandbox/microsandbox" @@ -11,20 +13,18 @@ import ( // Single-host providers pin the helper, runtime, firmware, image and resource // limits explicitly. The helper owns local paths; no ambient backend is selected. type Microsandbox struct { - HelperLeaseGeneration uint64 `json:"-"` - HelperLeasePath string `json:"-"` - HelperPath string `json:"helper_path"` - RuntimeHome string `json:"runtime_home"` - RuntimePath string `json:"runtime_path"` - FirmwarePath string `json:"firmware_path"` - RuntimeSHA256 string `json:"runtime_sha256"` - FirmwareSHA256 string `json:"firmware_sha256"` - Image string `json:"image"` - MemoryMiB uint32 `json:"memory_mib"` - CPUs uint8 `json:"cpus"` - RootDiskMiB uint32 `json:"root_disk_mib"` - EnvironmentDiskMiB uint32 `json:"environment_disk_mib"` - Network Network `json:"network"` + HelperPath string `json:"helper_path"` + RuntimeHome string `json:"runtime_home"` + RuntimePath string `json:"runtime_path"` + FirmwarePath string `json:"firmware_path"` + RuntimeSHA256 string `json:"runtime_sha256"` + FirmwareSHA256 string `json:"firmware_sha256"` + Image string `json:"image"` + MemoryMiB uint32 `json:"memory_mib"` + CPUs uint8 `json:"cpus"` + RootDiskMiB uint32 `json:"root_disk_mib"` + EnvironmentDiskMiB uint32 `json:"environment_disk_mib"` + Network Network `json:"network"` } type Network struct { @@ -41,7 +41,7 @@ type Rule struct { Port string `json:"port"` } -func configureMicrosandbox(entry Microsandbox, resources sandbox.Resources, result *Built) error { +func configureMicrosandbox(entry Microsandbox, resources sandbox.Resources, caller *sandboxmicro.ProcessCaller, result *Built) error { if !filepath.IsAbs(entry.RuntimeHome) || filepath.Clean(entry.RuntimeHome) != entry.RuntimeHome { return errors.New("managed microsandbox runtime_home must be a canonical absolute path") } @@ -53,7 +53,7 @@ func configureMicrosandbox(entry Microsandbox, resources sandbox.Resources, resu InstallationID: result.InstallationID, HelperPath: entry.HelperPath, RuntimeHome: entry.RuntimeHome, RuntimePath: entry.RuntimePath, FirmwarePath: entry.FirmwarePath, RuntimeSHA256: entry.RuntimeSHA256, FirmwareSHA256: entry.FirmwareSHA256, Image: entry.Image, MemoryMiB: entry.MemoryMiB, CPUs: entry.CPUs, RootDiskMiB: entry.RootDiskMiB, EnvironmentDiskMiB: entry.EnvironmentDiskMiB, Network: network, - }, &sandboxmicro.ProcessCaller{LeasePath: entry.HelperLeasePath, LeaseIdentity: sandboxmicro.LeaseIdentity{InstallationID: result.InstallationID, Generation: entry.HelperLeaseGeneration, SpecificationDigest: result.SpecificationDigest}}) + }, caller) if err != nil { return errors.New("invalid managed microsandbox provider configuration") } @@ -63,10 +63,29 @@ func configureMicrosandbox(entry Microsandbox, resources sandbox.Resources, resu return nil } -func buildMicrosandbox(c Config, result *Built) (func(), error) { +func buildMicrosandbox(c Config, options LocalOptions, result *Built) (func(), error) { closeProvider := func() {} if c.Microsandbox == nil || c.Docker != nil { return closeProvider, errors.New("managed microsandbox requires only the microsandbox configuration object") } - return closeProvider, configureMicrosandbox(*c.Microsandbox, c.Specification.Resources, result) + caller := &sandboxmicro.ProcessCaller{} + if options.GenerationStateDirectory != "" { + directory := filepath.Join(options.GenerationStateDirectory, "generations") + if err := os.MkdirAll(directory, 0700); err != nil { + return closeProvider, err + } + info, err := os.Lstat(directory) + if err != nil || !info.IsDir() || info.Mode().Perm() != 0700 { + return closeProvider, sandbox.ErrOwnership + } + caller.LeasePath = filepath.Join(directory, strconv.FormatUint(c.Generation, 10)+".lease") + caller.LeaseIdentity = sandboxmicro.LeaseIdentity{InstallationID: result.InstallationID, Generation: c.Generation, SpecificationDigest: result.SpecificationDigest} + } + if err := configureMicrosandbox(*c.Microsandbox, c.Specification.Resources, caller, result); err != nil { + return closeProvider, err + } + if options.GenerationStateDirectory != "" { + result.Probe = microsandboxGenerationProbe(*c.Microsandbox, result.Probe) + } + return closeProvider, nil } diff --git a/services/core/internal/sandbox/providers/probe.go b/services/core/internal/sandbox/providers/probe.go index 805958797..4ead40c4b 100644 --- a/services/core/internal/sandbox/providers/probe.go +++ b/services/core/internal/sandbox/providers/probe.go @@ -4,11 +4,14 @@ import ( "context" "crypto/sha256" "encoding/hex" + "encoding/json" "errors" "fmt" "io" "os" + "os/exec" "runtime" + "strings" "sync" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/sandbox" @@ -111,3 +114,32 @@ func verifyMicrosandboxArtifacts(entry Microsandbox) error { } return nil } + +// Image availability is checked on every retained-generation probe, after the +// platform, capacity and local artifact checks. +func microsandboxGenerationProbe(entry Microsandbox, probe func(context.Context) error) func(context.Context) error { + return func(ctx context.Context) error { + if err := probe(ctx); err != nil { + return err + } + command := exec.CommandContext(ctx, entry.RuntimePath, "image", "inspect", entry.Image, "--format", "json") + command.Env = append([]string{"PATH=/usr/local/bin:/usr/bin:/bin", "HOME=" + os.Getenv("HOME")}, "MSB_BACKEND=local", "MSB_HOME="+entry.RuntimeHome, "MSB_PATH="+entry.RuntimePath, "MSB_LIBKRUNFW_PATH="+entry.FirmwarePath) + reader, err := command.StdoutPipe() + if err != nil { + return sandbox.ErrRuntimeImageUnavailable + } + if err := command.Start(); err != nil { + return sandbox.ErrRuntimeImageUnavailable + } + raw, readErr := io.ReadAll(io.LimitReader(reader, 64*1024+1)) + if len(raw) > 64*1024 { + _ = command.Process.Kill() + } + waitErr := command.Wait() + var image struct{ Digest, Architecture, OS string } + if readErr != nil || waitErr != nil || len(raw) > 64*1024 || json.Unmarshal(raw, &image) != nil || image.Digest != strings.SplitN(entry.Image, "@", 2)[1] || image.Architecture != "amd64" || image.OS != "linux" { + return sandbox.ErrRuntimeImageUnavailable + } + return nil + } +} diff --git a/services/core/internal/sandbox/providers/registration_test.go b/services/core/internal/sandbox/providers/registration_test.go index 613c942cf..65fc3a355 100644 --- a/services/core/internal/sandbox/providers/registration_test.go +++ b/services/core/internal/sandbox/providers/registration_test.go @@ -65,7 +65,10 @@ func TestRegistrationRejectsBeforeCallbacksOrConstruction(t *testing.T) { } { t.Run(tc.name, func(t *testing.T) { a := adapters["docker"] - a.BuildLocal = func(Config, *Built) (func(), error) { t.Fatal("called local constructor"); return nil, nil } + a.BuildLocal = func(Config, LocalOptions, *Built) (func(), error) { + t.Fatal("called local constructor") + return nil, nil + } a.ValidateSpecification = func(sandbox.DeploymentSpec) error { t.Fatal("called specification validator"); return nil } a.ValidateResources = func(sandbox.Resources) error { t.Fatal("called resource validator"); return nil } a.Configuration = registrationConfiguration{requirements: a.Configuration.Requirements()} @@ -94,7 +97,7 @@ func TestRegistrationRejectsBeforeCallbacksOrConstruction(t *testing.T) { {"resolve change", func() error { _, err := ResolveChange(selection, selection); return err }}, {"credential", func() error { _, err := WithCredential(selection, selection); return err }}, {"local build", func() error { - _, _, err := Build(Config{Provider: kind, Generation: 1, InstallationID: uuid.NewString(), Specification: selection.DeploymentSpec}) + _, _, err := Build(Config{Provider: kind, Generation: 1, InstallationID: uuid.NewString(), Specification: selection.DeploymentSpec}, LocalOptions{Standalone: true}) return err }}, {"direct build", func() error { _, err := BuildDirect(DirectConfig{Selection: selection}); return err }}, @@ -126,19 +129,26 @@ func TestCompleteRegistrationsPreserveConstruction(t *testing.T) { } const kind = "new-test-provider" defer delete(adapters, kind) - calls := 0 + calls, closes := 0, 0 + options := LocalOptions{GenerationStateDirectory: t.TempDir()} a := adapters["docker"] - a.BuildLocal = func(_ Config, built *Built) (func(), error) { + a.BuildLocal = func(_ Config, got LocalOptions, built *Built) (func(), error) { calls++ + if got != options { + t.Fatalf("construction options = %+v, want %+v", got, options) + } built.Provider = &docker.Provider{} - return func() {}, nil + return func() { closes++ }, nil } adapters[kind] = a - built, closeProvider, err := Build(Config{Provider: kind, Generation: 1, InstallationID: uuid.NewString(), Specification: validRegistrationSpec()}) + built, closeProvider, err := Build(Config{Provider: kind, Generation: 1, InstallationID: uuid.NewString(), Specification: validRegistrationSpec()}, options) if err != nil || built.Provider == nil || calls != 1 { t.Fatalf("node build: %v calls=%d", err, calls) } closeProvider() + if closes != 1 { + t.Fatalf("provider close calls = %d", closes) + } // Direct providers may legitimately need no remote credential or extra // selection state; registration must not require irrelevant callback stubs. a.Mode, a.BuildLocal, a.NodeArtifacts = "direct", nil, nil diff --git a/services/core/internal/sandbox/providers/registry.go b/services/core/internal/sandbox/providers/registry.go index 8dac661e1..f579441f7 100644 --- a/services/core/internal/sandbox/providers/registry.go +++ b/services/core/internal/sandbox/providers/registry.go @@ -22,7 +22,7 @@ type Adapter struct { NodeArtifacts []providerassets.Artifact Policy sandbox.DeploymentPolicy Configuration sandbox.ConfigurationAdapter - BuildLocal func(Config, *Built) (func(), error) + BuildLocal func(Config, LocalOptions, *Built) (func(), error) BuildDirect func(DirectConfig) (sandbox.SandboxProvider, error) Mode string Operations func() providercontract.Operations