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
12 changes: 9 additions & 3 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,10 @@ SQLC_VERSION ?= v1.29.0
SQLC ?= go run github.com/sqlc-dev/sqlc/cmd/sqlc@$(SQLC_VERSION)
SWAG_VERSION ?= v1.16.4

.PHONY: help check check-database check-go check-sqlc sqlc-generate node-deps check-claude-sdk check-web check-mcode-harness build-daemon build-core check-core docker-build-core check-core-container build-agents-runtime build-claude-runtime build-claude-sdk-runtime build-mcode-harness build-mcode-runtime
.PHONY: help check check-database check-go check-sqlc sqlc-generate node-deps check-claude-sdk check-web check-mcode-harness build-daemon build-sandbox-io build-core check-core docker-build-core check-core-container build-agents-runtime build-claude-runtime build-claude-sdk-runtime build-mcode-harness build-mcode-runtime

help:
@printf '%s\n' 'make build-core Build standalone Core commands' 'make build-daemon Build the execution daemon' 'make check Run Core, persistence and runtime checks' 'See README.md for runtime prerequisites and deployment.'
@printf '%s\n' 'make build-core Build standalone Core commands' 'make build-daemon Build the execution daemon' 'make build-sandbox-io Build the Sandbox I/O service for Linux' 'make check Run Core, persistence and runtime checks' 'See README.md for runtime prerequisites and deployment.'

check: check-ci check-harness-catalog check-names check-distribution check-database check-sqlc check-go check-microsandbox-provider check-core check-claude-sdk check-web check-example check-mcode-harness
@printf 'OpenAgentCore checks passed.\n'
Expand Down Expand Up @@ -47,7 +47,7 @@ check-sqlc:
python3 scripts/check-sqlc.py

check-go:
go test ./apps/daemon/... ./internal/... ./contracts/agents-api/... ./scripts/openapi-split -count=1
go test ./apps/daemon/... ./apps/sandboxio/... ./internal/... ./contracts/agents-api/... ./scripts/openapi-split -count=1

.PHONY: check-runtime-contract
check-runtime-contract:
Expand All @@ -61,6 +61,12 @@ build-daemon:
mkdir -p "$$output"; \
CGO_ENABLED=0 go build -mod=readonly -trimpath -o "$$output/oac-daemon" ./apps/daemon/cmd/oac-daemon

build-sandbox-io:
@set -e; output="$${OAC_DEV_HOME:-$$HOME/.oac}/build/sandbox-io"; \
[[ "$$output" == /* ]] || { echo 'Sandbox I/O output directory must be absolute' >&2; exit 1; }; \
mkdir -p "$$output"; \
GOOS=linux CGO_ENABLED=0 go build -mod=readonly -trimpath -o "$$output/oac-sandbox-io" ./apps/sandboxio/cmd/oac-sandbox-io

build-core:
./scripts/build-core.sh

Expand Down
51 changes: 51 additions & 0 deletions apps/sandboxio/cmd/oac-sandbox-io/main.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
//go:build linux

// Command oac-sandbox-io is the Sandbox I/O service, the one process a
// Sandbox Provider starts in a sandbox. docs/sandbox-bootstrap.md describes
// its launch.
package main

import (
"context"
"flag"
"fmt"
"io"
"os"
"os/signal"

"golang.org/x/sys/unix"

"github.com/MiniMax-AI/OpenAgentCore/apps/sandboxio/internal/processservice"
"github.com/MiniMax-AI/OpenAgentCore/apps/sandboxio/internal/sandboxio"
)

func main() {
// A process launch re-executes this binary as a trampoline; Init runs it.
processservice.Init()

flags := flag.NewFlagSet("oac-sandbox-io", flag.ContinueOnError)
flags.SetOutput(io.Discard)
bootstrapFile := flags.String("bootstrap-file", "", "")
if flags.Parse(os.Args[1:]) != nil || *bootstrapFile == "" || flags.NArg() != 0 {
fmt.Fprintln(os.Stderr, "usage: oac-sandbox-io --bootstrap-file <absolute path>")
os.Exit(2)
}

// Reap is the process's only wait. As a child subreaper it also reaps
// the orphaned descendants of operations.
if err := unix.Prctl(unix.PR_SET_CHILD_SUBREAPER, 1, 0, 0, 0); err != nil {
fail(&sandboxio.StartupError{Step: sandboxio.StepSubreaper, Err: err})
}
go processservice.Reap(context.Background())

ctx, stop := signal.NotifyContext(context.Background(), unix.SIGTERM, unix.SIGINT)
defer stop()
if err := sandboxio.Run(ctx, *bootstrapFile); err != nil {
fail(err)
}
}

func fail(err error) {
fmt.Fprintf(os.Stderr, "oac-sandbox-io: %v\n", err)
os.Exit(1)
}
17 changes: 17 additions & 0 deletions apps/sandboxio/internal/processservice/scope.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ package processservice

import (
"bytes"
"context"
"errors"
"fmt"
"io/fs"
Expand Down Expand Up @@ -416,6 +417,22 @@ func (op *operation) watchScope() {
}
}

// awaitScope waits until the scope has closed or ctx ends. A launch that
// failed closes the scope too.
func (op *operation) awaitScope(ctx context.Context) {
stop := context.AfterFunc(ctx, func() {
op.mu.Lock()
op.cond.Broadcast()
op.mu.Unlock()
})
defer stop()
op.mu.Lock()
defer op.mu.Unlock()
for op.scope != sp.ScopeStateClosed && ctx.Err() == nil {
op.cond.Wait()
}
}

func (op *operation) scopeClosed() {
op.mu.Lock()
defer op.mu.Unlock()
Expand Down
18 changes: 18 additions & 0 deletions apps/sandboxio/internal/processservice/service.go
Original file line number Diff line number Diff line change
Expand Up @@ -322,6 +322,24 @@ func (s *Service) AttachmentRevoked(id sandboxwire.ID) {
s.cancelAll(ops)
}

// Shutdown ends the incarnation's operations when the service stops: it
// cancels every operation as ownership cleanup does, with TERM, then KILL
// after the grace limit, and returns once every operation's scope has closed
// or ctx ends. The binary calls it after its streams have ended, so no Start
// arrives during or after it; Reap must still be running.
func (s *Service) Shutdown(ctx context.Context) {
s.mu.Lock()
ops := make([]*operation, 0, len(s.ops))
for _, op := range s.ops {
ops = append(ops, op)
}
s.mu.Unlock()
s.cancelAll(ops)
for _, op := range ops {
op.awaitScope(ctx)
}
}

func (s *Service) stopGraceLocked(id sandboxwire.ID) {
if t := s.owners[id]; t != nil {
t.Stop()
Expand Down
124 changes: 124 additions & 0 deletions apps/sandboxio/internal/sandboxio/sandboxio.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
//go:build linux

// Package sandboxio assembles the Sandbox I/O service: it reads the
// Provider's bootstrap, connects to the relay as the Link serve peer and
// serves the File and Process protocols on the streams the relay binds.
// docs/sandbox-bootstrap.md describes the launch.
package sandboxio

import (
"context"
"crypto/tls"
"log"
"sync/atomic"
"time"

"github.com/MiniMax-AI/OpenAgentCore/apps/sandboxio/internal/fileservice"
"github.com/MiniMax-AI/OpenAgentCore/apps/sandboxio/internal/processservice"
"github.com/MiniMax-AI/OpenAgentCore/internal/runtimefs"
"github.com/MiniMax-AI/OpenAgentCore/internal/sandboxbootstrap"
"github.com/MiniMax-AI/OpenAgentCore/internal/sandboxfs"
"github.com/MiniMax-AI/OpenAgentCore/internal/sandboxlink"
"github.com/MiniMax-AI/OpenAgentCore/internal/sandboxprocess"
"github.com/MiniMax-AI/OpenAgentCore/internal/sandboxwire"
)

// Step names the startup step a StartupError failed in.
type Step string

const (
StepSubreaper Step = "become a child subreaper"
StepBootstrap Step = "read the bootstrap file"
StepProcessService Step = "start the process service"
StepFileService Step = "start the file service"
)

// StartupError is a failure before the service serves. Its message names the
// step and never includes the credential.
type StartupError struct {
Step Step
Err error
}

func (e *StartupError) Error() string { return string(e.Step) + ": " + e.Err.Error() }
func (e *StartupError) Unwrap() error { return e.Err }

// shutdownMargin is how long Shutdown waits past the grace limit for killed
// processes to be observed gone.
const shutdownMargin = 5 * time.Second

// Run serves the sandbox the bootstrap file at bootstrapPath names, with the
// export world rooted at "/", until ctx ends or the relay refuses the link
// for good. The caller has made the process a child subreaper running
// processservice.Reap. Run returns nil when ctx ended, a *StartupError when
// it could not start, and otherwise the relay's *sandboxlink.Error.
func Run(ctx context.Context, bootstrapPath string) error {
return run(ctx, bootstrapPath, options{root: "/"})
}

// options are the seams tests use: a temporary export root and a TLS
// configuration that trusts a test relay. Run uses "/" and the system roots.
type options struct {
root string
tls *tls.Config
}

func run(ctx context.Context, bootstrapPath string, opt options) error {
raw, err := runtimefs.ReadPrivatePath(bootstrapPath, sandboxbootstrap.MaxBytes)
if err != nil {
return &StartupError{StepBootstrap, err}
}
in, err := sandboxbootstrap.Decode(raw)
if err != nil {
return &StartupError{StepBootstrap, err}
}
procCfg := processservice.DefaultConfig()
procs, err := processservice.New(procCfg)
if err != nil {
return &StartupError{StepProcessService, err}
}
files, err := fileservice.New(opt.root)
if err != nil {
return &StartupError{StepFileService, err}
}
defer files.Close()

// down records that a link attempt failed since the last accepted Hello,
// so each drop is logged once rather than on every reconnect attempt.
var down atomic.Bool
serveErr := sandboxlink.Serve(ctx, sandboxlink.ServeConfig{
URL: in.LinkURL,
TLS: opt.tls,
Credential: []byte(in.Credential),
Resource: in.Resource.Ref(),
// The File service checks each stream's binding against its own
// incarnation, so the link announces that one.
ServerInstanceID: files.InstanceID(),
Services: []sandboxlink.ServiceHandler{
{Service: sandboxlink.ServiceFile, Version: sandboxfs.Version, Serve: func(ctx context.Context, b sandboxlink.Bind, s sandboxlink.Stream) {
sandboxfs.Serve(ctx, s, files, sandboxfs.Attachment{ID: b.AttachmentID, ServerInstanceID: b.ExpectedServerInstanceID, Lease: ctx, Exports: b.Exports})
}},
{Service: sandboxlink.ServiceProcess, Version: sandboxprocess.Version, Serve: func(ctx context.Context, b sandboxlink.Bind, s sandboxlink.Stream) {
sandboxprocess.Serve(ctx, s, sandboxprocess.Attachment{ID: b.AttachmentID}, procs)
}},
},
OnConnected: func(sandboxlink.HelloAccepted) { down.Store(false) },
OnDisconnected: func(err error) {
if !down.Swap(true) {
log.Printf("oac-sandbox-io: relay link ended, reconnecting: %v", err)
}
},
OnAttachmentLost: procs.AttachmentLost,
OnAttachmentRestored: procs.AttachmentRestored,
OnAttachmentClosed: func(id sandboxwire.ID, _ sandboxlink.CloseReason) { procs.AttachmentRevoked(id) },
})

// Serve accepts no more streams and every handler has returned.
shutdown, cancel := context.WithTimeout(context.WithoutCancel(ctx), procCfg.CancelGraceLimit+shutdownMargin)
defer cancel()
procs.Shutdown(shutdown)
if ctx.Err() != nil {
return nil
}
return serveErr
}
Loading
Loading