From 58a16eebe1918bd09e51687104fbe85f67526a21 Mon Sep 17 00:00:00 2001 From: SaladDay <1203511142@qq.com> Date: Thu, 1 Oct 2026 00:38:53 +0000 Subject: [PATCH] Add the oac-sandbox-io binary oac-sandbox-io reads the Provider's bootstrap file, becomes a child subreaper running the one reap loop, and serves the File service (world at /) and the Process service as the Link serve peer, with attachment loss, restore and close wired to the process ownership hooks. SIGTERM stops accepting streams and cancels live operations through the new processservice Shutdown before exiting. Startup failures exit nonzero with a typed step and never the credential. Adds make build-sandbox-io (static Linux), runs apps/sandboxio in check-go, routes apps/sandboxio changes to the backend CI job, and documents the launch, responsibilities and readiness. --- Makefile | 12 +- apps/sandboxio/cmd/oac-sandbox-io/main.go | 51 ++++ .../internal/processservice/scope.go | 17 ++ .../internal/processservice/service.go | 18 ++ .../sandboxio/internal/sandboxio/sandboxio.go | 124 ++++++++++ .../internal/sandboxio/sandboxio_test.go | 226 ++++++++++++++++++ docs/development.md | 5 +- docs/file-access-protocol.md | 2 +- docs/process-protocol.md | 2 +- docs/sandbox-bootstrap.md | 22 +- docs/sandbox-link-protocol.md | 4 +- scripts/ci_plan.py | 1 + scripts/ci_plan_test.py | 1 + 13 files changed, 470 insertions(+), 15 deletions(-) create mode 100644 apps/sandboxio/cmd/oac-sandbox-io/main.go create mode 100644 apps/sandboxio/internal/sandboxio/sandboxio.go create mode 100644 apps/sandboxio/internal/sandboxio/sandboxio_test.go diff --git a/Makefile b/Makefile index b43b111f..f038365c 100644 --- a/Makefile +++ b/Makefile @@ -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' @@ -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: @@ -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 diff --git a/apps/sandboxio/cmd/oac-sandbox-io/main.go b/apps/sandboxio/cmd/oac-sandbox-io/main.go new file mode 100644 index 00000000..94666fd8 --- /dev/null +++ b/apps/sandboxio/cmd/oac-sandbox-io/main.go @@ -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 ") + 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) +} diff --git a/apps/sandboxio/internal/processservice/scope.go b/apps/sandboxio/internal/processservice/scope.go index 0955ef0f..0972702f 100644 --- a/apps/sandboxio/internal/processservice/scope.go +++ b/apps/sandboxio/internal/processservice/scope.go @@ -4,6 +4,7 @@ package processservice import ( "bytes" + "context" "errors" "fmt" "io/fs" @@ -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() diff --git a/apps/sandboxio/internal/processservice/service.go b/apps/sandboxio/internal/processservice/service.go index 394a44ec..abf83d5e 100644 --- a/apps/sandboxio/internal/processservice/service.go +++ b/apps/sandboxio/internal/processservice/service.go @@ -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() diff --git a/apps/sandboxio/internal/sandboxio/sandboxio.go b/apps/sandboxio/internal/sandboxio/sandboxio.go new file mode 100644 index 00000000..9a377e58 --- /dev/null +++ b/apps/sandboxio/internal/sandboxio/sandboxio.go @@ -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 +} diff --git a/apps/sandboxio/internal/sandboxio/sandboxio_test.go b/apps/sandboxio/internal/sandboxio/sandboxio_test.go new file mode 100644 index 00000000..f7d6cfe5 --- /dev/null +++ b/apps/sandboxio/internal/sandboxio/sandboxio_test.go @@ -0,0 +1,226 @@ +//go:build linux + +package sandboxio + +import ( + "context" + "errors" + "os" + "path/filepath" + "strconv" + "strings" + "sync" + "testing" + "time" + + "github.com/google/uuid" + "golang.org/x/sys/unix" + + "github.com/MiniMax-AI/OpenAgentCore/apps/sandboxio/internal/processservice" + "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/sandboxlink/relay" + "github.com/MiniMax-AI/OpenAgentCore/internal/sandboxlink/sandboxlinktest" + sp "github.com/MiniMax-AI/OpenAgentCore/internal/sandboxprocess" + "github.com/MiniMax-AI/OpenAgentCore/internal/sandboxwire" +) + +// TestMain runs like oac-sandbox-io's main: the trampoline hook first, then a +// child subreaper whose one reap loop owns every child exit. +func TestMain(m *testing.M) { + processservice.Init() + if err := unix.Prctl(unix.PR_SET_CHILD_SUBREAPER, 1, 0, 0, 0); err != nil { + panic(err) + } + go processservice.Reap(context.Background()) + os.Exit(m.Run()) +} + +const wait = 10 * time.Second + +// writeBootstrap writes a bootstrap file for a fresh resource. +func writeBootstrap(t *testing.T, linkURL, credential string) (string, sandboxlink.ResourceRef) { + t.Helper() + in := sandboxbootstrap.Input{Version: sandboxbootstrap.Version, LinkURL: linkURL, Credential: credential, Resource: sandboxbootstrap.Resource{ + TenantID: uuid.NewString(), EnvironmentID: uuid.NewString(), Kind: "allocation", ID: uuid.NewString(), Generation: 1}} + raw, err := in.Marshal() + if err != nil { + t.Fatal(err) + } + path := filepath.Join(t.TempDir(), "bootstrap.json") + if err := os.WriteFile(path, raw, 0o600); err != nil { + t.Fatal(err) + } + return path, in.Resource.Ref() +} + +// start runs the service with the export world at root. The returned stop +// ends it as SIGTERM does and returns run's result. +func start(t *testing.T, bootstrap, root string, srv *sandboxlinktest.Server) (stop func() error) { + ctx, cancel := context.WithCancel(context.Background()) + done := make(chan error, 1) + go func() { done <- run(ctx, bootstrap, options{root: root, tls: srv.TLS}) }() + stop = sync.OnceValue(func() error { + cancel() + select { + case err := <-done: + return err + case <-time.After(wait): + return errors.New("the service did not stop") + } + }) + t.Cleanup(func() { stop() }) + return stop +} + +// open opens a service stream, retrying while no serve peer is connected. +func open(link *sandboxlink.AttachLink, o sandboxlink.Open) (sandboxlink.Stream, sandboxlink.Opened, error) { + for deadline := time.Now().Add(wait); ; time.Sleep(20 * time.Millisecond) { + ctx, cancel := context.WithTimeout(context.Background(), wait) + s, opened, err := link.OpenService(ctx, o) + cancel() + if !errors.Is(err, sandboxlink.ServiceUnavailable) || time.Now().After(deadline) { + return s, opened, err + } + } +} + +func shell(script string) sp.ProcessSpec { + return sp.ProcessSpec{Executable: []byte("sh"), Argv: [][]byte{[]byte("sh"), []byte("-c"), []byte(script)}, + Env: []sp.EnvVar{{Name: []byte("PATH"), Value: []byte("/usr/bin:/bin")}}, Cwd: []byte("/"), Umask: 0o022, + IOMode: sp.IOPipes, Scope: sp.ScopePOSIXSession} +} + +func next(t *testing.T, op *sp.Operation) sp.Event { + t.Helper() + select { + case ev := <-op.Events(): + return ev + case <-time.After(wait): + t.Fatal("no event") + return nil + } +} + +func TestServesFileAndProcessThroughTheRelay(t *testing.T) { + ctx := context.Background() + auth := sandboxlinktest.NewAuthority() + srv := sandboxlinktest.StartRelay(t, relay.Config{Authority: auth}) + bootstrap, resource := writeBootstrap(t, srv.URL, "serve-credential") + auth.AddServe([]byte("serve-credential"), sandboxlink.ServePeer{PeerID: sandboxwire.NewID(), Resource: resource}) + runtimeID := sandboxwire.NewID() + auth.AddRuntime([]byte("runtime-credential"), runtimeID) + o := sandboxlink.Open{Resource: resource, AttachmentID: sandboxwire.NewID(), SessionID: sandboxwire.NewID(), + AssignmentID: sandboxwire.NewID(), AssignmentEpoch: 1, AttachGrant: []byte("grant")} + auth.AddGrant(o.AttachGrant, sandboxlinktest.Grant{RuntimeID: runtimeID, Resource: resource, SessionID: o.SessionID, + AssignmentID: o.AssignmentID, AssignmentEpoch: 1, Services: []sandboxlink.Service{sandboxlink.ServiceFile, sandboxlink.ServiceProcess}, Lease: time.Minute}) + link, err := sandboxlink.DialAttach(ctx, sandboxlink.AttachConfig{URL: srv.URL, TLS: srv.TLS, RuntimeID: runtimeID, Credential: []byte("runtime-credential")}) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { link.Close() }) + root := t.TempDir() + stop := start(t, bootstrap, root, srv) + + // A file created through the world export is the file under its root. + o.Service, o.Version = sandboxlink.ServiceFile, sandboxfs.Version + stream, opened, err := open(link, o) + if err != nil { + t.Fatal(err) + } + files := sandboxfs.NewClient(stream) + defer files.Close() + attached, err := files.Attach(ctx, &sandboxfs.AttachRequest{Export: "world"}) + if err != nil { + t.Fatal(err) + } + created, err := files.Create(ctx, &sandboxfs.CreateRequest{Parent: attached.Root.Node, Name: []byte("note"), Mode: 0o644, Access: sandboxfs.AccessReadWrite, Exclusive: true}) + if err != nil { + t.Fatal(err) + } + if w, err := files.Write(ctx, &sandboxfs.WriteRequest{Handle: created.Handle, Data: []byte("hello")}); err != nil || w.Written != 5 { + t.Fatalf("write: %+v, %v", w, err) + } + if r, err := files.Read(ctx, &sandboxfs.ReadRequest{Handle: created.Handle, Size: 64}); err != nil || string(r.Data) != "hello" { + t.Fatalf("read: %+v, %v", r, err) + } + if b, err := os.ReadFile(filepath.Join(root, "note")); err != nil || string(b) != "hello" { + t.Fatalf("on disk: %q, %v", b, err) + } + + // A process runs to completion, with its exit and the end of its output + // reported as separate events. + o.Service, o.Version = sandboxlink.ServiceProcess, sp.Version + stream, _, err = open(link, o) + if err != nil { + t.Fatal(err) + } + procs := sp.NewClient(stream) + defer procs.Close() + described, err := procs.Describe(ctx) + if err != nil { + t.Fatal(err) + } + op, _, err := procs.Start(ctx, described.ServerInstanceID, sandboxwire.NewID(), shell("echo hi; exit 7")) + if err != nil { + t.Fatal(err) + } + var output strings.Builder + var exited *sp.ExitedEvent + for outputClosed := false; exited == nil || !outputClosed; { + switch ev := next(t, op).(type) { + case sp.OutputEvent: + output.Write(ev.Data) + case sp.ExitedEvent: + exited = &ev + case sp.OutputClosedEvent: + outputClosed = true + } + } + if output.String() != "hi\n" || exited.Status != (sp.ExitStatus{Kind: sp.ExitCode, Code: 7}) { + t.Fatalf("output %q, exit %+v", output.String(), exited.Status) + } + + // Stopping the service terminates its live operations before it returns. + op, _, err = procs.Start(ctx, described.ServerInstanceID, sandboxwire.NewID(), shell("echo $$; exec sleep 600")) + if err != nil { + t.Fatal(err) + } + var line strings.Builder + for !strings.HasSuffix(line.String(), "\n") { + if ev, ok := next(t, op).(sp.OutputEvent); ok { + line.Write(ev.Data) + } + } + pid, err := strconv.Atoi(strings.TrimSpace(line.String())) + if err != nil { + t.Fatal(err) + } + if err := stop(); err != nil { + t.Fatalf("stop: %v", err) + } + if err := unix.Kill(pid, 0); err != unix.ESRCH { + t.Fatalf("process %d after stop: %v", pid, err) + } + + // A restarted service is a new instance. + start(t, bootstrap, root, srv) + o.ExpectedServerInstanceID = opened.ServerInstanceID + if _, _, err := open(link, o); !errors.Is(err, sandboxlink.InstanceChanged) { + t.Fatalf("reopen after restart: %v, want InstanceChanged", err) + } +} + +// A refused serve credential ends the service with the relay's typed failure, +// and the message never carries the credential. +func TestRefusedCredentialEndsTheService(t *testing.T) { + srv := sandboxlinktest.StartRelay(t, relay.Config{Authority: sandboxlinktest.NewAuthority()}) + bootstrap, _ := writeBootstrap(t, srv.URL, "unknown-credential") + ctx, cancel := context.WithTimeout(context.Background(), wait) + defer cancel() + err := run(ctx, bootstrap, options{root: t.TempDir(), tls: srv.TLS}) + if !errors.Is(err, sandboxlink.AuthenticationFailed) || strings.Contains(err.Error(), "unknown-credential") { + t.Fatalf("run: %v, want AuthenticationFailed without the credential", err) + } +} diff --git a/docs/development.md b/docs/development.md index db1595bf..a6b676dc 100644 --- a/docs/development.md +++ b/docs/development.md @@ -45,9 +45,10 @@ From the repository root: ```sh make build-core make build-daemon +make build-sandbox-io ``` -Core build outputs and output-directory settings are in [Standalone Core builds](maintainers.md#standalone-core-builds). The daemon is written to `${OAC_DEV_HOME:-$HOME/.oac}/build/daemon/oac-daemon`. +Core build outputs and output-directory settings are in [Standalone Core builds](maintainers.md#standalone-core-builds). The daemon is written to `${OAC_DEV_HOME:-$HOME/.oac}/build/daemon/oac-daemon`. The Sandbox I/O service is written to `${OAC_DEV_HOME:-$HOME/.oac}/build/sandbox-io/oac-sandbox-io` as a static Linux binary. Use the [service guide](../services/core/README.md#run-from-source) to run the Core migrator and server with a separate development database. The [configuration appendix](configuration.md#appendix-core-environment-without-the-installer) owns standalone process settings. For a complete operator installation, use the [installation guide](getting-started/install.md); building Core alone is a separate contributor workflow. @@ -68,7 +69,7 @@ For frontend development, run `pnpm dev:web` using the fixture or Core connectio | `internal/sandboxbootstrap` | Provider-to-Sandbox I/O service startup input | [Sandbox bootstrap](sandbox-bootstrap.md) | | `internal/sandboxfs` | Runtime–file service wire types, validators, client and server | [File access protocol](file-access-protocol.md) | | `internal/sandboxprocess` | Runtime–process service wire types, validators, client and server | [Process protocol](process-protocol.md) | -| `apps/sandboxio` | Sandbox I/O service and its Linux protocol services | [File access protocol](file-access-protocol.md#the-linux-service) | +| `apps/sandboxio` | Sandbox I/O service binary `oac-sandbox-io` and its Linux protocol services | [Sandbox bootstrap](sandbox-bootstrap.md#responsibilities-and-readiness), [File access protocol](file-access-protocol.md#the-linux-service), [Process protocol](process-protocol.md#implement-a-service) | | `apps/daemon/internal/dispatch` | Runtime preparation, Executor reuse, Turn and cleanup ownership | [Harness lifecycle](../contracts/agents-api/harness-onboarding.md#required-adapter-interfaces) | | `apps/daemon/internal/agent` | Native harness adapters | [Native references](../contracts/agents-api/harness-onboarding.md#native-references) | | `services/core/internal/sandbox` | Provider interfaces and managed compute lifecycle | [Provider onboarding](sandbox-provider.md) | diff --git a/docs/file-access-protocol.md b/docs/file-access-protocol.md index 1496f95d..de6b6553 100644 --- a/docs/file-access-protocol.md +++ b/docs/file-access-protocol.md @@ -50,7 +50,7 @@ A service must: ### The Linux service -`fileservice.New(root)` serves the absolute directory `root` as the one export `world`, and `Describe` lists `world` only to an attachment granted it. `oac-sandbox-io` passes `/`; the Provider's sandbox setup owns the isolation of everything under it, as the [Sandbox bootstrap](sandbox-bootstrap.md#responsibilities) states, and the service enforces no boundary inside the export. `New` sets the process umask to zero and reports its effective UID and GID as `Identity`. `InstanceID` returns the `ServerInstanceID` to give Link, and `Close` releases every attachment. +`fileservice.New(root)` serves the absolute directory `root` as the one export `world`, and `Describe` lists `world` only to an attachment granted it. `oac-sandbox-io` passes `/`; the Provider's sandbox setup owns the isolation of everything under it, as the [Sandbox bootstrap](sandbox-bootstrap.md#responsibilities-and-readiness) states, and the service enforces no boundary inside the export. `New` sets the process umask to zero and reports its effective UID and GID as `Identity`. `InstanceID` returns the `ServerInstanceID` to give Link, and `Close` releases every attachment. - Each node holds an `O_PATH|O_NOFOLLOW` descriptor. In an attachment a node is one mount ID, device and inode, so hard links share a node while a bind mount and its source stay two. The mount ID comes from `statx` with `STATX_MNT_ID`, or from the `mnt_id` line of `/proc/self/fdinfo/` on kernels older than 5.8. - A lookup opens one component with `openat` and `O_NOFOLLOW` on its parent's descriptor. A symlink, including a proc magic link such as `/proc//cwd`, is a node of its own and is never traversed: `Lookup` and `Readlink` return the link itself, a directory operation on it fails with `Errno` `NotDirectory`, and `Open` fails with `SymlinkLoop`. diff --git a/docs/process-protocol.md b/docs/process-protocol.md index 1ba06e20..9aad8504 100644 --- a/docs/process-protocol.md +++ b/docs/process-protocol.md @@ -36,7 +36,7 @@ A service must: The Linux service calls `processservice.Init()` first in the binary's `main`. Go cannot set a child's umask, so each launch re-executes the service binary as a trampoline that reads the launch from an inherited descriptor, marks every inherited descriptor above 2 close-on-exec, applies the umask and working directory, and execs the target. `Init` runs that trampoline and returns at once in a normal start. The Linux service launches every operation in a new session with `setsid`, observes the session through `/proc`, and advertises `ScopePOSIXSession` only. It requires `pidfd_open` and `pidfd_send_signal` (Linux 5.3 or later): without them `processservice.New` fails with `ErrPidfdUnsupported`. -Before serving, `main` makes the process a child subreaper (`prctl(PR_SET_CHILD_SUBREAPER)`) and runs `processservice.Reap(ctx)` for the life of the process. `Reap` is the process's only `wait`: it reaps every child, delivers each leader's exit to its operation, and reaps the orphaned descendants the subreaper inherits. Nothing else in the binary may wait for children, and no operation observes an exit while `Reap` is not running. +Before serving, `main` makes the process a child subreaper (`prctl(PR_SET_CHILD_SUBREAPER)`) and runs `processservice.Reap(ctx)` for the life of the process. `Reap` is the process's only `wait`: it reaps every child, delivers each leader's exit to its operation, and reaps the orphaned descendants the subreaper inherits. Nothing else in the binary may wait for children, and no operation observes an exit while `Reap` is not running. When the binary stops, it calls `Shutdown(ctx)` after its streams have ended: `Shutdown` cancels every live operation as [ownership cleanup](#ownership) does and returns once each scope has closed or `ctx` ends. ## Reference diff --git a/docs/sandbox-bootstrap.md b/docs/sandbox-bootstrap.md index d0a229c6..cd349b7c 100644 --- a/docs/sandbox-bootstrap.md +++ b/docs/sandbox-bootstrap.md @@ -4,7 +4,13 @@ A Sandbox Provider starts the Sandbox I/O service by handing it one bootstrap fi ## Launch input -Deliver one JSON object in a regular file that only the service's account and trusted provisioning processes can read (mode 0600 on Linux), and pass its absolute path to the service when starting it. +Deliver one JSON object in a regular file that only the service's account and trusted provisioning processes can read (mode 0600 on Linux), and pass its absolute path: + +```sh +oac-sandbox-io --bootstrap-file /home/sandbox/sandbox-io-bootstrap.json +``` + +The command takes no other argument and reads no environment variable or configuration file. | Field | Meaning | | --- | --- | @@ -24,14 +30,18 @@ The file is the service's only authentication input. Credentials never go in com The service runs as the account the Provider starts it with. The input names no user or group, and the service never changes identity. The Provider already creates the sandbox's accounts and launches its processes, so it chooses this account, and the service needs no privilege-dropping code. -## Responsibilities +## Responsibilities and readiness + +The Provider creates the account and the sandbox, delivers this file and starts `oac-sandbox-io` as that account. `make build-sandbox-io` builds the static Linux binary. The Provider keeps the file for process restarts and removes it only during explicit cleanup of the resources it owns. + +The service validates the input and owns the link: it connects as the serve peer, serves bound streams and reconnects while the credential stays valid. `resource`, including its generation, must be the resource the credential serves, or the relay refuses the link. -The Provider creates the account and the sandbox, delivers this file and starts the service. It keeps the file for process restarts and removes it only during explicit cleanup of the resources it owns. +The File service serves the single export `world`, rooted at the sandbox's `/`, and the Provider's sandbox setup owns that topology's isolation. The [Process service](process-protocol.md#implement-a-service) runs processes as the service's account, and the service, a child subreaper, reaps their orphaned descendants. -The service validates the input and owns the link: it connects, serves bound streams and reconnects while the credential stays valid. `resource`, including its generation, must be the resource the credential serves, or the relay refuses the link. +The service exits nonzero with a message naming the failed step when it cannot start, and with the relay's failure code when `Serve` returns a [refusal](sandbox-link-protocol.md#implement-a-serve-peer). Neither message includes the credential. On SIGTERM it stops accepting streams, cancels its live operations as [ownership cleanup](process-protocol.md#ownership) does, waits for them to end, at most the cancel grace limit plus five seconds, and exits 0. -The File service serves the single export `world`, rooted at the sandbox's `/`, and the Provider's sandbox setup owns that topology's isolation. +A successful launch proves only the handoff. The service is ready when the relay holds it as the resource's current serve peer, so that an `Open` of the resource reaches it instead of failing with `ServiceUnavailable`. ## Verification -`go test ./internal/sandboxbootstrap` covers the input contract. +`go test ./internal/sandboxbootstrap` covers the input contract, and `go test ./apps/sandboxio/internal/sandboxio` runs the service against a test relay on Linux. diff --git a/docs/sandbox-link-protocol.md b/docs/sandbox-link-protocol.md index 71d59505..b5f023c9 100644 --- a/docs/sandbox-link-protocol.md +++ b/docs/sandbox-link-protocol.md @@ -32,7 +32,7 @@ The agent-host Runtime calls `sandboxlink.DialAttach` with its Runtime ID and cr - `OpenService` sends `Open` on a new stream and returns the stream and `Opened`. A refusal returns a `*sandboxlink.Error`, and `errors.Is(err, sandboxlink.PermissionDenied)` matches its code. The context bounds the open, not the stream. - `Renew` extends an attachment's lease with a current grant before `LeaseExpiresAt` passes. `CloseAttachment` ends an attachment and all its streams. Both wait for their turn to write and for the answer only as long as their context allows. -- A call whose context ends before its request is sent returns `ServiceUnavailable` with `EffectNone` and leaves the link up. A call whose request may have reached the relay but that ends without an answer, because its context ended or the link dropped, returns `ServiceUnavailable` with `EffectPossible` (`sandboxlink.Uncertain`). A context that ends while a control request is being written ends the link, because the control stream cannot carry a partial frame; one that ends after the request is written only stops the wait. +- A call whose context ends before its request is sent returns `ServiceUnavailable` with `EffectNone` and leaves the link up. A call whose request may have reached the relay but that ends without an answer, because its context ended or the link dropped, returns `ServiceUnavailable` with `EffectPossible` (`sandboxlink.Uncertain`). A context that ends while a control request is being written ends the link, because the control stream cannot carry a partial frame; one that ends after the request is written only stops the wait. A cancel that races the completion of a write may still close the link, and calls in flight then fail with `EffectPossible`. - `OnAttachmentClosed` reports the relay closing an attachment for lease expiry, revocation or a newer resource generation. An attachment outlives its link. After reconnecting, the Runtime opens a stream with the same binding identity. To resume state the service holds, it sets `ExpectedServerInstanceID` to the `ServerInstanceID` of the earlier `Opened`; `InstanceChanged` then means the service restarted and lost that state. After an attachment closes, the Runtime opens a new one under a new `AttachmentID`. @@ -220,7 +220,7 @@ An attachment's binding identity is its `AttachmentID`, `Resource`, `SessionID`, `Bind` carries the authorized binding and never the grant or a credential: -- For a File stream, `Exports` lists 1 to 64 exports the stream may use, each by ID and read-write or `ReadOnly`. An export ID is 1 to 64 lowercase letters, digits, `_` and `-`, and IDs in a list are distinct. Process and Network binds carry no `Exports`. The [Sandbox bootstrap](sandbox-bootstrap.md#responsibilities) states which exports the File service serves. +- For a File stream, `Exports` lists 1 to 64 exports the stream may use, each by ID and read-write or `ReadOnly`. An export ID is 1 to 64 lowercase letters, digits, `_` and `-`, and IDs in a list are distinct. Process and Network binds carry no `Exports`. The [Sandbox bootstrap](sandbox-bootstrap.md#responsibilities-and-readiness) states which exports the File service serves. - For a Network stream, `Egress` lists the destinations the stream may reach: an address inside a rule's prefix on a port from `PortFirst` to `PortLast`. An empty list denies everything. Each prefix has its host bits zero, `1 ≤ PortFirst ≤ PortLast`, and no rule appears twice. File and Process binds carry no `Egress`. The exports and egress of a stream are fixed when it opens; a renewal changes only the lease. diff --git a/scripts/ci_plan.py b/scripts/ci_plan.py index 7b76da22..e732436a 100644 --- a/scripts/ci_plan.py +++ b/scripts/ci_plan.py @@ -22,6 +22,7 @@ (("services/core/internal/nativeinstaller/",), ("native", "distribution")), (("services/core/deploy/", "services/core/tools/"), ("distribution",)), (("apps/daemon/",), ("backend", "native")), + (("apps/sandboxio/",), ("backend",)), (("internal/",), ("backend", "api", "native", "distribution")), (("internal/harnessconfig/",), ("web", "web-acceptance", "example", "harness")), (("contracts/",), ("backend", "api", "native", "web", "web-acceptance", "example", "distribution")), diff --git a/scripts/ci_plan_test.py b/scripts/ci_plan_test.py index 6f52cdd6..928283a7 100644 --- a/scripts/ci_plan_test.py +++ b/scripts/ci_plan_test.py @@ -26,6 +26,7 @@ def test_web_and_core_have_different_consumers(self): plan = ci.select(["services/core/internal/store/sessions.go"]) self.assertEqual(set(plan["jobs"]), {"hygiene", "backend", "api"}) self.assertFalse(plan["image"]) + self.assertEqual(self.jobs("apps/sandboxio/cmd/oac-sandbox-io/main.go"), {"hygiene", "backend"}) def test_image_and_native_inputs_keep_their_acceptance(self): self.assertTrue(ci.select(["deploy/distribution/Dockerfile"])["image"])