Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions docs/sandbox-provider.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
43 changes: 2 additions & 41 deletions services/core/cmd/sandbox-node/generations.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
34 changes: 34 additions & 0 deletions services/core/cmd/sandbox-node/generations_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
})
}
}
2 changes: 1 addition & 1 deletion services/core/cmd/sandbox-node/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down
22 changes: 20 additions & 2 deletions services/core/internal/sandbox/providers/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import (
"errors"
"io"
"os"
"path/filepath"

"github.com/MiniMax-AI/OpenAgentCore/services/core/internal/sandbox"
"github.com/google/uuid"
Expand Down Expand Up @@ -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
Expand All @@ -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
}
Expand Down
2 changes: 1 addition & 1 deletion services/core/internal/sandbox/providers/docker.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
82 changes: 82 additions & 0 deletions services/core/internal/sandbox/providers/generation_probe_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
}
Loading