From ed43316a3f9ce3f50c2aee46ea426d6407fc6554 Mon Sep 17 00:00:00 2001 From: saladday <1203511142@qq.com> Date: Mon, 28 Sep 2026 17:49:33 +0000 Subject: [PATCH 1/2] Define the SandboxProvider onboarding contract and shared failure tests --- CONTRIBUTING.md | 19 ++ Makefile | 5 + README.md | 1 + docs/sandbox-provider.md | 183 ++++++++++++++++++ .../cmd/server/managed_generations.go | 2 +- .../agents-api/cmd/server/managed_setup.go | 4 +- .../archive_cancellation_cleanup_test.go | 2 +- .../internal/execution/runtime_adoption.go | 2 +- .../internal/execution/runtime_lifecycle.go | 4 +- .../runtime_retirement_failure_test.go | 2 +- .../execution/sandbox_deployment_setup.go | 2 +- .../sandbox_provider_contract_test.go | 54 ++++++ .../internal/sandbox/config/config.go | 2 +- .../internal/sandbox/contracttest/provider.go | 96 +++++++++ .../internal/sandbox/docker/contract_test.go | 140 ++++++++++++++ .../internal/sandbox/docker/provider.go | 2 +- .../internal/sandbox/docker/provider_test.go | 8 +- .../internal/sandbox/e2b/contract_test.go | 57 ++++++ .../internal/sandbox/e2b/provider.go | 2 +- .../sandbox/microsandbox/contract_test.go | 72 +++++++ .../internal/sandbox/microsandbox/provider.go | 2 +- .../agents-api/internal/sandbox/node/agent.go | 4 +- .../internal/sandbox/node/connection_test.go | 2 +- .../internal/sandbox/node/generations.go | 4 +- .../internal/sandbox/node/observations.go | 2 +- .../sandbox/node/observations_test.go | 4 +- .../agents-api/internal/sandbox/node/proxy.go | 8 +- .../agents-api/internal/sandbox/node/wire.go | 2 +- .../agents-api/internal/sandbox/provider.go | 63 ------ .../internal/sandbox/sandbox_provider.go | 106 ++++++++++ .../agents-api/internal/sandbox/suspension.go | 17 -- .../internal/store/runtime_lifecycle_test.go | 4 +- 32 files changed, 763 insertions(+), 114 deletions(-) create mode 100644 docs/sandbox-provider.md create mode 100644 services/agents-api/internal/execution/sandbox_provider_contract_test.go create mode 100644 services/agents-api/internal/sandbox/contracttest/provider.go create mode 100644 services/agents-api/internal/sandbox/docker/contract_test.go create mode 100644 services/agents-api/internal/sandbox/e2b/contract_test.go create mode 100644 services/agents-api/internal/sandbox/microsandbox/contract_test.go delete mode 100644 services/agents-api/internal/sandbox/provider.go create mode 100644 services/agents-api/internal/sandbox/sandbox_provider.go diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 3bcc4d162..919d92543 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -59,6 +59,25 @@ isolation remain separate work. the pinned public API, or authorize unrelated refactors or live Session configuration switching. +### Sandbox Provider contract + +The canonical Sandbox Provider entry point is +[`sandbox_provider.go`](services/agents-api/internal/sandbox/sandbox_provider.go), +with integration and operation semantics in [the onboarding guide](docs/sandbox-provider.md). +Use `sandbox.SandboxProvider` for compute lifecycle and bounded bootstrap operations; +checkpoint and read-only observation remain separate optional interfaces. +Registration/construction owns vendor configuration. Core's common lifecycle +selects optional behavior by interface capability, not a provider name. + +Retain the persisted allocation identity and cleanup responsibility after unknown +results. Never replay Create or initialization commands on transport failure. +Compute running, authenticated Runtime connection, prepared capabilities and Turn +acceptance are separate facts. A confirmed Kill cannot by itself settle an +outstanding Create. Capability preparation belongs to Runtime, and releasing an +executor does not reclaim its Environment. Do not add provider-specific execution +or preparation paths. New adapters must run the shared Sandbox Provider contract +suite through their native boundary and qualify their real resource behavior. + ## Workflow and quality Develop in an isolated worktree on a feature branch and submit a PR. Do not edit diff --git a/Makefile b/Makefile index d72d18bdd..6cac8dd95 100644 --- a/Makefile +++ b/Makefile @@ -153,3 +153,8 @@ check-e2b-provider: .PHONY: check-docs check-docs: node-deps pnpm check:docs + +# These packages are also exercised by check-agents-api in the full gate. +.PHONY: check-sandbox-provider-contract +check-sandbox-provider-contract: + go test ./services/agents-api/internal/sandbox/... -count=1 diff --git a/README.md b/README.md index c8f255455..8867ba767 100644 --- a/README.md +++ b/README.md @@ -82,6 +82,7 @@ native differences. | [Nodes and sandbox backends: operator reference](services/agents-api/HOSTED-SANDBOX-MANAGER.md) | Node protocol, manual registration, placement, reset | | [Protocol coverage](contracts/agents-api/README.md) and [harness selection](contracts/agents-api/harness-selection.md) | Supported operations and native differences | | [Add a Harness](contracts/agents-api/harness-onboarding.md) | Required adapter interfaces, lifecycle, capabilities, registration and acceptance | +| [Add a Sandbox Provider](docs/sandbox-provider.md) | Required lifecycle, optional capabilities, registration, ownership and contract tests | | [Landing page source](site/index.html) | The public site | ### Maintainers and advanced deployments diff --git a/docs/sandbox-provider.md b/docs/sandbox-provider.md new file mode 100644 index 000000000..1a96727a1 --- /dev/null +++ b/docs/sandbox-provider.md @@ -0,0 +1,183 @@ +# Add a Sandbox Provider + +Start at [`sandbox_provider.go`](../services/agents-api/internal/sandbox/sandbox_provider.go). +It defines the required `SandboxProvider` and optional `CheckpointProvider` +interfaces. Use the existing types; do not introduce another lifecycle protocol or +an execution path for a particular vendor. + +Core owns durable Environment, allocation, placement and cleanup state. A Sandbox +Provider supplies compute and the bootstrap needed to start its Runtime (daemon). +Runtime prepares capabilities and workspaces through the common daemon protocol; +Harness adapters translate execution into native operations. The same Runtime +preparation and execution contract applies to a user-owned machine. That machine +has no Core-owned compute allocation to create or destroy. + +Sandbox isolation belongs to the outer infrastructure. A daemon running directly +on a user's machine runs with that user's authority; installing the daemon does +not establish a filesystem, tool or network sandbox. Provider isolation and +network enforcement must be qualified independently of daemon connectivity. + +## What to implement + +| Contract | Requirement | Responsibility | +| --- | --- | --- | +| `sandbox.SandboxProvider` | Required | `Create`, `GetInfo`, `Renew`, `Kill`, and bounded bootstrap/diagnostic `RunCommand` | +| `sandbox.CheckpointProvider` | Optional, separate interface | Exact compute incarnations, snapshot capture/restore, retained-source resume and cleanup | +| `runtimeobs.Source` | Optional, separate interface | Read-only, ownership-checked resource observations | +| `runtimeobs.BatchSource` | Optional, separate interface | Bounded observations in input order, with per-target errors; `ok=false` means no batch read occurred | + +Do not implement an optional interface with successful no-op methods. Core uses +interface assertions to select optional operations. Advertised support requires +contract and native acceptance evidence; a healthy node or an available CLI does +not establish it. Observation never renews a lease, starts compute or prepares a +Harness. See the [observation contract](../contracts/agents-api/runtime-observability.md). + +`RunCommand` is an existing administrative bootstrap/diagnostic facility, not an +alternate route for Skills, Plugins, MCP setup, initial files, Session execution or +Files. Those use Runtime. Confidential command input travels in `Command.Stdin`, +not argv or logs. Preserve its byte order, bounded output and actual exit status. + +## Register an adapter + +Registration is explicit construction, not an init-time plugin registry: + +1. Put native SDK calls, ownership checks, identity translation and private + configuration in an adapter package under `services/agents-api/internal/sandbox`. + Assert `var _ sandbox.SandboxProvider = (*YourAdapter)(nil)` at compile time. +2. For a node-local adapter, add its typed construction and readiness probe in + [`sandbox/config`](../services/agents-api/internal/sandbox/config/config.go). + The existing `Built` result returns the provider, probe, installation identity, + backend fingerprint and specification digest; the factory also returns its + close function. Construction must not create an Environment. +3. For a direct adapter, register its construction in + [`managedSetup.provider`](../services/agents-api/cmd/server/managed_setup.go). + Node-local adapters are selected there through the existing node proxy. Keep + vendor-specific deployment validation and SDK setup at this boundary. +4. Preserve the existing `execution.RuntimeProvider` deployment binding: + `ProviderKind`, installation ID, backend fingerprint, generation, mode and + node ownership identify the selected backend. The database owns the selection; + the in-memory selection is not an alternate authority. Register optional + interfaces on direct adapters and their transport wrappers consistently. +5. Qualify the provider's operator configuration and resource limits in the + existing deployment specification/enrollment path. The shipped deployment + parser and persistence constraints explicitly enumerate supported backends; + update that registration surface when adding one. Do not bypass it with an + arbitrary provider string. This is deployment admission, not a new execution + workflow. Adding a new public capability requires its own protocol change. + +Today Docker and microsandbox use nodes; E2B is constructed directly. The node +proxy exposes checkpoint operations only for its registered checkpoint-capable +backend. A new node backend must register both construction and the corresponding +proxy capability; otherwise that capability is unavailable. Provider names belong +in this adapter/configuration wiring, not Session scheduling, capability +preparation or Turn execution. Common lifecycle code uses `CheckpointProvider` +to admit suspension, independently of a provider name. + +The backend fingerprint identifies a native resource namespace, not mutable +capacity. Core retains deployment generations so old owned allocations continue +to resolve to their original backend. This is resource ownership, not historical +binary compatibility. Do not repoint retained allocations at a replacement backend. + +## Identity and resource responsibility + +`Reference` is the exact `(TenantID, EnvironmentID, AllocationID)` tuple. Core +persists a fresh allocation ID **before** `Create`; it is not the Environment ID. +The adapter also binds resources to its installation. Do not locate or authorize +resources by a bare native ID, display name, guessed path or an unverified label. +All mutations, reads and cleanup must verify the same ownership. + +Core serializes lifecycle operations and retains the allocation after any +uncertain mutation. An adapter must preserve enough native identity/receipts for +observation and cleanup. A failed call may return both `Info` and an error: +preserve reference-bound settlement evidence without converting failure to success. +Adapters must not silently create replacement resources, overwrite credentials or +switch to a new allocation after a conflict. + +`Info.ProviderID` and `Info.State` describe compute. Core recognizes `running` +only with the matching Reference and a native identity. Other native states can +be observed without claiming readiness. `BootstrapComplete` says the bootstrap +reached its final mutating step. `CreateSettled` proves the original attempt can +no longer mutate resources; it does not mean success. In particular: + +- `State="absent"` plus matching Reference and `CreateSettled=true` is an explicit + creation-absence receipt, with no native ID or completed bootstrap. +- `ErrNotFound`, an empty listing, a timeout or a successful `Kill` alone is not + proof that an in-flight create cannot appear later. +- Core can also settle creation from a matching running resource with completed + bootstrap. It must retain unknown creation until it has such evidence or an + explicit receipt, even if a cleanup attempt currently sees no resources. + +`Kill` owns cleanup of the allocation's compute and retained resources, including +partial bootstrap storage. It must not remove another tenant's resource on a +name collision. Core releases durable ownership only after confirmed cleanup +**and** settled creation. Executor release or Harness cancellation does not delete +an Environment, its workspace or its Sandbox Provider allocation. + +## Operation outcomes and retries + +All calls receive bounded contexts. Expiry or cancellation ends the caller's +wait; it does not prove rollback, native stop, cleanup or absence. An adapter or +transport must not detach untracked mutations or replay a timed-out command. + +| Operation | Confirmed result | Failure or unknown result | Recovery | +| --- | --- | --- | --- | +| `Create` | Matching compute and bootstrap evidence; execution still needs Runtime preparation | Invalid/foreign configuration rejects; duplicate returns `ErrExists`; transport failure may hide created resources | Observe the original Reference. Never replay `Create`, even with a new credential. Preserve partial resources for owned cleanup. | +| `GetInfo` | Current compute observation without mutation | `ErrNotFound` is only a missing observation; an error is not absence proof | Repeat a bounded read; never turn it into create/start/renew. | +| `Renew` | Existing native lease extended, or an observation for a provider without leases | Timeout may hide a lease extension; stopped/missing compute stays stopped/missing | Observe then let the normal reconciler renew the same allocation. Never revive it or fabricate a lease expiry. | +| `Kill` | Owned compute and retained storage removed; repeated confirmed absence succeeds | Error retains ownership and cleanup intent; ownership mismatch must not delete foreign resources | Retry cleanup of the same Reference after outstanding creation/mutation is fenced. Do not release the durable owner early. | +| `RunCommand` | Collected output and actual exit code; nonzero exit is a settled command failure | Missing native completion is `ErrCommandUnconfirmed`; partial output is not success | Do not replay. Preserve the owner and reclaim before reuse when completion cannot be proved. | + +Use `ErrInvalid`, `ErrOwnership`, `ErrExists`, `ErrNotFound`, +`ErrComputeUnconfirmed` and `ErrCommandUnconfirmed` for their existing meanings. +An unclassified native/transport error is conservatively unknown, not permission +to retry a mutation. Core must not interpret provider diagnostics as lifecycle +truth or expose native error text/credentials. The node boundary maps errors to +fixed codes; direct SDK details stay private. + +Checkpoint support adds `Compute` generation/name/ID and `SnapshotIdentity`. +Persist operation IDs and provider-returned snapshot provenance unchanged. +`ObserveOnly` on suspend/resume observes the previous attempt and must not start +another capture or restore. `ResumeCompute` only thaws the retained source; it +must not cold-start a stopped one. Cleanup targets the exact compute incarnation +and snapshot, not whichever instance currently has the same display name. See +[the lifecycle implementation](../services/agents-api/internal/execution/runtime_compute.go) +and its failure tests before advertising this capability. + +## Four distinct readiness facts + +| Fact | Evidence | Does not establish | +| --- | --- | --- | +| Compute available | Provider observation for the owned allocation | Authenticated Runtime connection or prepared capabilities | +| Runtime connected | Gateway authentication and exact Environment/device binding | Completed Runtime preparation or a usable Harness | +| Capabilities prepared | Successful common Runtime preparation with the fixed configuration/snapshot | Acceptance or completion of a Turn | +| Execution admitted | Qualified Harness capabilities and the existing executor/Turn acceptance path | A completed input, cancellation or reclaimed compute | + +For both hosted and self-hosted environments, Core uses the daemon preparation +and execution protocol. Core resolves configuration and supplies resources; +Runtime installs/reads local capabilities and freezes the result for the Session. +Reconnection can reuse that prepared content; a new Session takes a new snapshot. +A provider must not implement a competing preparation path. + +## Validate the integration + +Run `make check-sandbox-provider-contract` while developing. It runs the shared +[`contracttest`](../services/agents-api/internal/sandbox/contracttest) suite through +real adapter boundaries using controlled native failures, plus existing adapter +and node transport tests. The same packages are included in `make check`. +New adapters should call the public failure runner with native-side fixtures, +not substitute a fake implementation of `SandboxProvider` for the adapter under +test. Preserve tests for foreign ownership, unknown mutation results, cancellation, +no automatic replay, failed cleanup and reference-bound settlement. + +Node tests separately exercise disconnect/reconnect fencing and cleanup after a +lost create response. Provider helper protocols and the node protocol require an +exact version match and reject mismatches; do not add fallback decoders or old +binary migration. Direct in-process interfaces have no independent wire version. + +Run `make check` with its dedicated database before completion. Retain native +acceptance for SDK behavior that fixtures cannot prove: creation, lease behavior, +owned partial cleanup, declared isolation/limits, and snapshots where supported. +The opt-in Docker lifecycle/recovery tests use `AGENTS_RUNTIME_DOCKER_TEST_IMAGE`; +SDK helper tests use `make check-e2b-provider` and +`make check-microsandbox-provider`. Passing controlled contract tests is not a +claim of live cloud or model acceptance. diff --git a/services/agents-api/cmd/server/managed_generations.go b/services/agents-api/cmd/server/managed_generations.go index 310addfc2..ff978435d 100644 --- a/services/agents-api/cmd/server/managed_generations.go +++ b/services/agents-api/cmd/server/managed_generations.go @@ -27,7 +27,7 @@ type e2bGenerationRouter struct { store generationStore } -func (p *e2bGenerationRouter) route(ctx context.Context, r sandbox.Reference) (sandbox.Provider, func(), error) { +func (p *e2bGenerationRouter) route(ctx context.Context, r sandbox.Reference) (sandbox.SandboxProvider, func(), error) { release, err := p.setup.e2bCalls.Enter(ctx) if err != nil { return nil, nil, err diff --git a/services/agents-api/cmd/server/managed_setup.go b/services/agents-api/cmd/server/managed_setup.go index df7f21c4e..19b0399c6 100644 --- a/services/agents-api/cmd/server/managed_setup.go +++ b/services/agents-api/cmd/server/managed_setup.go @@ -133,7 +133,7 @@ func (s *managedSetup) configuration(setup store.SandboxSetup) (execution.Prepar } selected := &execution.RuntimeProvider{InstallationID: setup.InstallationID, ProviderKind: setup.Provider, Generation: setup.Generation, Mode: setup.Mode, AdmissionPaused: setup.AdmissionPaused, CoreURL: s.publicURL + "/api/v1", BackendFingerprint: setup.BackendFingerprint, Provider: provider} - if setup.Provider == "microsandbox" { + if _, supportsCheckpoint := provider.(sandbox.CheckpointProvider); supportsCheckpoint { selected.Suspension = &execution.RuntimeSuspensionPolicy{IdleTimeout: time.Duration(setup.IdleSeconds) * time.Second, Retention: time.Duration(setup.RetentionSeconds) * time.Second, MaxActive: 4, MaxRetained: 16} } @@ -176,7 +176,7 @@ func (s *managedSetup) Observe(ctx context.Context, target runtimeobs.Target) (r return source.Observe(ctx, target) } -func (s *managedSetup) provider(setup store.SandboxSetup) (sandbox.Provider, error) { +func (s *managedSetup) provider(setup store.SandboxSetup) (sandbox.SandboxProvider, error) { switch setup.Provider { case "docker", "microsandbox": if s.hub == nil { 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 4ccfa7374..637e70ef6 100644 --- a/services/agents-api/internal/execution/archive_cancellation_cleanup_test.go +++ b/services/agents-api/internal/execution/archive_cancellation_cleanup_test.go @@ -25,7 +25,7 @@ import ( // Provider callbacks inspect the real database at the instant destructive // cleanup is invoked. They do not create containers or claim native evidence. type waitingCleanupProvider struct { - sandbox.Provider + sandbox.SandboxProvider beforeKill func() } diff --git a/services/agents-api/internal/execution/runtime_adoption.go b/services/agents-api/internal/execution/runtime_adoption.go index 0fdb2f885..7a425f8f1 100644 --- a/services/agents-api/internal/execution/runtime_adoption.go +++ b/services/agents-api/internal/execution/runtime_adoption.go @@ -11,7 +11,7 @@ import ( // VerifyLegacyRuntimeOwnership uses only positive, read-only Provider observations. // It runs before node placement and never becomes an execution fallback route. -func VerifyLegacyRuntimeOwnership(ctx context.Context, p sandbox.Provider, allocation store.RuntimeAllocation) (err error) { +func VerifyLegacyRuntimeOwnership(ctx context.Context, p sandbox.SandboxProvider, allocation store.RuntimeAllocation) (err error) { failure := "resource_unconfirmed" defer func() { if err == nil { diff --git a/services/agents-api/internal/execution/runtime_lifecycle.go b/services/agents-api/internal/execution/runtime_lifecycle.go index cf00b5d29..113857c08 100644 --- a/services/agents-api/internal/execution/runtime_lifecycle.go +++ b/services/agents-api/internal/execution/runtime_lifecycle.go @@ -35,7 +35,7 @@ type RuntimeProvider struct { CoreURL string InstallationID string BackendFingerprint string - Provider sandbox.Provider + Provider sandbox.SandboxProvider AdmissionPaused bool Suspension *RuntimeSuspensionPolicy } @@ -95,7 +95,7 @@ func validatedRuntimeProvider(config *RuntimeProvider, registry *gateway.Registr if copied.Mode != "" && copied.Mode != "nodes" && copied.Mode != "direct" { return RuntimeProvider{}, sandbox.ErrInvalid } - if copied.Mode == "direct" && (copied.ProviderKind != "e2b" || copied.LocalNodeID != "" || copied.Suspension != nil) { + if copied.Mode == "direct" && (copied.ProviderKind == "" || copied.LocalNodeID != "" || copied.Suspension != nil) { return RuntimeProvider{}, sandbox.ErrInvalid } if config.Suspension != nil { diff --git a/services/agents-api/internal/execution/runtime_retirement_failure_test.go b/services/agents-api/internal/execution/runtime_retirement_failure_test.go index 18096d7ee..8be2b55b6 100644 --- a/services/agents-api/internal/execution/runtime_retirement_failure_test.go +++ b/services/agents-api/internal/execution/runtime_retirement_failure_test.go @@ -217,7 +217,7 @@ func TestFailedInventoryRetirementClosesAdmissionAndRetainsGate(t *testing.T) { } // No provider method is called by the retirement ownership fixture. -type drainFixtureProvider struct{ sandbox.Provider } +type drainFixtureProvider struct{ sandbox.SandboxProvider } // Done is evaluated only after lockMutation's initial admission check, letting // this test close admission while a request is already waiting for its gate. diff --git a/services/agents-api/internal/execution/sandbox_deployment_setup.go b/services/agents-api/internal/execution/sandbox_deployment_setup.go index d31c9ce0f..c2c494c6e 100644 --- a/services/agents-api/internal/execution/sandbox_deployment_setup.go +++ b/services/agents-api/internal/execution/sandbox_deployment_setup.go @@ -111,7 +111,7 @@ func (m *runtimeManager) ensureDeployment(parent context.Context) (bool, error) if err != nil || config == nil { return false, err } - if config.InstallationID != m.setupInstallationID || config.LocalNodeID != "" || config.loadDeployment != nil || (config.ProviderKind != "docker" && config.ProviderKind != "microsandbox" && config.ProviderKind != "e2b") { + if config.InstallationID != m.setupInstallationID || config.LocalNodeID != "" || config.loadDeployment != nil || config.ProviderKind == "" { return false, sandbox.ErrInvalid } copied, err := validatedRuntimeProvider(config, m.registry) diff --git a/services/agents-api/internal/execution/sandbox_provider_contract_test.go b/services/agents-api/internal/execution/sandbox_provider_contract_test.go new file mode 100644 index 000000000..1dfe0a915 --- /dev/null +++ b/services/agents-api/internal/execution/sandbox_provider_contract_test.go @@ -0,0 +1,54 @@ +package execution + +import ( + "context" + "strings" + "testing" + + "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/gateway" + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/sandbox" + "github.com/google/uuid" +) + +// A trusted registration is sufficient for the common deployment loader. Public +// configuration admission still validates its explicitly registered backend. +func TestSandboxProviderRegistrationDoesNotRequireAnExecutionVendorBranch(t *testing.T) { + for _, mode := range []string{"nodes", "direct"} { + t.Run(mode, func(t *testing.T) { + id := uuid.NewString() + config := &RuntimeProvider{InstallationID: id, ProviderKind: "contract-fixture", Mode: mode, + CoreURL: "https://core.example/api/v1", BackendFingerprint: strings.Repeat("a", 64), Provider: &lifecycleOnlySandbox{}} + m, err := newRuntimeManager(nil, gateway.NewRegistry(), NewDeferredRuntimeProvider(id, func(context.Context) (*RuntimeProvider, error) { return config, nil })) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { m.stop(); m.drain() }) + ready, err := m.ensureDeployment(t.Context()) + if err != nil || !ready { + t.Fatalf("registered provider cannot enter common lifecycle: %v", err) + } + if m.config.Provider != config.Provider || m.config.ProviderKind != config.ProviderKind || m.config.Mode != mode { + t.Fatal("registration identity changed") + } + }) + } +} + +// Methods must not run during registration; the loader only binds an adapter. +type lifecycleOnlySandbox struct{} + +func (*lifecycleOnlySandbox) Create(context.Context, sandbox.Bootstrap) (sandbox.Info, error) { + panic("registration created compute") +} +func (*lifecycleOnlySandbox) GetInfo(context.Context, sandbox.Reference) (sandbox.Info, error) { + panic("registration read compute") +} +func (*lifecycleOnlySandbox) Renew(context.Context, sandbox.Reference) (sandbox.Info, error) { + panic("registration renewed compute") +} +func (*lifecycleOnlySandbox) Kill(context.Context, sandbox.Reference) error { + panic("registration killed compute") +} +func (*lifecycleOnlySandbox) RunCommand(context.Context, sandbox.Reference, sandbox.Command) (sandbox.CommandResult, error) { + panic("registration executed command") +} diff --git a/services/agents-api/internal/sandbox/config/config.go b/services/agents-api/internal/sandbox/config/config.go index 880208ed9..8f9d68e34 100644 --- a/services/agents-api/internal/sandbox/config/config.go +++ b/services/agents-api/internal/sandbox/config/config.go @@ -65,7 +65,7 @@ func Load(file string) (Config, error) { type Built struct { SpecificationDigest string - Provider sandbox.Provider + Provider sandbox.SandboxProvider InstallationID, BackendFingerprint string Probe func(context.Context) error } diff --git a/services/agents-api/internal/sandbox/contracttest/provider.go b/services/agents-api/internal/sandbox/contracttest/provider.go new file mode 100644 index 000000000..f72a8ee79 --- /dev/null +++ b/services/agents-api/internal/sandbox/contracttest/provider.go @@ -0,0 +1,96 @@ +// Package contracttest runs shared lifecycle assertions against real adapters +// backed by controlled native transports. It is test support only. +package contracttest + +import ( + "context" + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/sandbox" + "reflect" + "testing" + "time" +) + +type Fault string + +const ( + TransportFailure Fault = "transport_failure" + Canceled Fault = "canceled_after_dispatch" + ForeignOwnership Fault = "foreign_ownership" + CleanupFailure Fault = "cleanup_failure" +) + +type Scenario struct { + Operation string + Fault Fault +} + +// Fixture exposes the production adapter. Calls records all native requests; +// WantCalls includes legitimate multi-step work before the injected fault. Exact +// comparison detects replay, fallback and implicit cleanup after uncertain Create. +type Fixture struct { + Provider sandbox.SandboxProvider + Bootstrap sandbox.Bootstrap + Calls func() []string + WantCalls []string +} + +// Factory configures native responses. Invoke cancel only after dispatch begins. +type Factory func(t *testing.T, scenario Scenario, cancel context.CancelFunc) Fixture + +// RunFailures requires errors without mandating one native error type. Failed +// or canceled calls must not invent proof of settlement or bootstrap completion. +func RunFailures(t *testing.T, factory Factory) { + t.Helper() + for _, operation := range []string{"create", "inspect", "renew", "kill"} { + faults := []Fault{TransportFailure, Canceled, ForeignOwnership} + if operation == "kill" { + faults = append(faults, CleanupFailure) + } + for _, fault := range faults { + t.Run(operation+"/"+string(fault), func(t *testing.T) { + ctx, cancel := context.WithTimeout(t.Context(), 5*time.Second) + defer cancel() + f := factory(t, Scenario{operation, fault}, cancel) + var info sandbox.Info + var err error + switch operation { + case "create": + info, err = f.Provider.Create(ctx, f.Bootstrap) + case "inspect": + info, err = f.Provider.GetInfo(ctx, f.Bootstrap.Reference) + case "renew": + info, err = f.Provider.Renew(ctx, f.Bootstrap.Reference) + case "kill": + err = f.Provider.Kill(ctx, f.Bootstrap.Reference) + } + if err == nil { + t.Fatal("native failure was reported as success") + } + if fault == Canceled && ctx.Err() == nil { + t.Fatal("fixture did not cancel the dispatched operation") + } + if info.CreateSettled || info.BootstrapComplete { + t.Fatalf("failed operation invented settlement or bootstrap proof: %+v", info) + } + if info.Reference != (sandbox.Reference{}) && info.Reference != f.Bootstrap.Reference { + t.Fatalf("failure exposed a foreign reference: %+v", info.Reference) + } + if got := f.Calls(); !reflect.DeepEqual(got, f.WantCalls) { + t.Fatalf("native calls = %v, want %v; unexpected replay, mutation or cleanup", got, f.WantCalls) + } + }) + } + } +} + +// AssertObservation checks identity and the expected native state, without +// equating a running process with authenticated Runtime or executor readiness. +func AssertObservation(t *testing.T, got sandbox.Info, err error, reference sandbox.Reference, providerID, state string) { + t.Helper() + if err != nil { + t.Fatal(err) + } + if got.Reference != reference || got.ProviderID == "" || providerID != "" && got.ProviderID != providerID || got.State != state { + t.Fatalf("observation lost identity or native state: %+v", got) + } +} diff --git a/services/agents-api/internal/sandbox/docker/contract_test.go b/services/agents-api/internal/sandbox/docker/contract_test.go new file mode 100644 index 000000000..622f78171 --- /dev/null +++ b/services/agents-api/internal/sandbox/docker/contract_test.go @@ -0,0 +1,140 @@ +package docker + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "sync" + "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" + "github.com/moby/moby/client" +) + +// The script covers only native calls before a fault. Full Docker creation and +// bootstrap remain covered by the existing opt-in lifecycle and recovery tests. +type contractStep struct { + method, path string + status int + body any + fault contracttest.Fault +} + +func dockerContractFixture(t *testing.T, cancel context.CancelFunc, script func(*Provider, sandbox.Reference) []contractStep) contracttest.Fixture { + t.Helper() + b := sandbox.Bootstrap{Reference: sandbox.Reference{TenantID: uuid.NewString(), EnvironmentID: uuid.NewString(), AllocationID: uuid.NewString()}, SessionID: uuid.NewString(), DeviceID: uuid.NewString(), CoreURL: "https://core.example/api/v1", Credential: "synthetic", NetworkAccess: "enabled"} + var mu sync.Mutex + var calls []string + var steps []contractStep + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + path := strings.TrimPrefix(r.URL.Path, "/v1.52") + call := r.Method + " " + path + mu.Lock() + index := len(calls) + calls = append(calls, call) + mu.Unlock() + if index >= len(steps) || call != steps[index].method+" "+steps[index].path { + t.Errorf("unexpected native request: %s", call) + http.Error(w, "unexpected request", 500) + return + } + step := steps[index] + switch step.fault { + case contracttest.Canceled: + cancel() + return + case contracttest.TransportFailure: + connection, _, err := w.(http.Hijacker).Hijack() + if err != nil { + t.Error(err) + return + } + _ = connection.Close() + return + } + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(step.status) + if step.body != nil { + _ = json.NewEncoder(w).Encode(step.body) + } + })) + t.Cleanup(server.Close) + c, err := client.New(client.WithHost(server.URL), client.WithAPIVersion("1.52")) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = c.Close() }) + p, err := New(c, Config{InstallationID: uuid.NewString(), Image: "sha256:" + strings.Repeat("a", 64), Network: "bridge", Seccomp: "{}"}) + if err != nil { + t.Fatal(err) + } + steps = script(p, b.Reference) + want := make([]string, len(steps)) + for i, step := range steps { + want[i] = step.method + " " + step.path + } + return contracttest.Fixture{Provider: p, Bootstrap: b, Calls: func() []string { + mu.Lock() + defer mu.Unlock() + return append([]string(nil), calls...) + }, WantCalls: want} +} + +func TestProviderContract(t *testing.T) { + contracttest.RunFailures(t, func(t *testing.T, s contracttest.Scenario, cancel context.CancelFunc) contracttest.Fixture { + return dockerContractFixture(t, cancel, func(p *Provider, r sandbox.Reference) []contractStep { + inspect := "/containers/" + p.name(r) + "/json" + home, environment := "/volumes/"+p.name(r)+"-home", "/volumes/"+p.name(r)+"-environment" + missing := map[string]string{"message": "not found"} + owned := map[string]any{"Id": "native-owned", "Config": map[string]any{"Labels": p.labels(r)}, "State": map[string]string{"Status": "running"}} + foreign := map[string]any{"Labels": map[string]string{"io.oac.tenant": uuid.NewString()}} + if s.Fault == contracttest.ForeignOwnership { + if s.Operation == "kill" { + // Verify every ownership check precedes any deletion, including volumes. + return []contractStep{{http.MethodGet, inspect, 200, owned, ""}, {http.MethodGet, home, 200, foreign, ""}} + } + return []contractStep{{http.MethodGet, inspect, 200, map[string]any{"Id": "foreign", "Config": foreign}, ""}} + } + switch s.Operation { + case "create": + return []contractStep{ + {http.MethodGet, inspect, 404, missing, ""}, + {http.MethodGet, home, 404, missing, ""}, + {http.MethodGet, environment, 404, missing, ""}, + {http.MethodPost, "/volumes/create", 0, nil, s.Fault}, + } + case "kill": + volume := map[string]any{"Labels": p.labels(r)} + steps := []contractStep{{http.MethodGet, inspect, 200, owned, ""}, {http.MethodGet, home, 200, volume, ""}, {http.MethodGet, environment, 200, volume, ""}} + if s.Fault == contracttest.CleanupFailure { + return append(steps, contractStep{http.MethodDelete, "/containers/native-owned", 204, nil, ""}, contractStep{http.MethodDelete, home, 500, map[string]string{"message": "cleanup failed"}, ""}) + } + return append(steps, contractStep{http.MethodDelete, "/containers/native-owned", 0, nil, s.Fault}) + default: + return []contractStep{{http.MethodGet, inspect, 0, nil, s.Fault}} + } + }) + }) +} + +func TestProviderContractObservation(t *testing.T) { + for _, operation := range []string{"inspect", "renew"} { + t.Run(operation, func(t *testing.T) { + f := dockerContractFixture(t, func() {}, func(p *Provider, r sandbox.Reference) []contractStep { + return []contractStep{{http.MethodGet, "/containers/" + p.name(r) + "/json", 200, map[string]any{"Id": "native-owned", "Config": map[string]any{"Labels": p.labels(r)}, "State": map[string]string{"Status": "exited"}}, ""}} + }) + var got sandbox.Info + var err error + if operation == "renew" { + got, err = f.Provider.Renew(t.Context(), f.Bootstrap.Reference) + } else { + got, err = f.Provider.GetInfo(t.Context(), f.Bootstrap.Reference) + } + contracttest.AssertObservation(t, got, err, f.Bootstrap.Reference, "native-owned", "exited") + }) + } +} diff --git a/services/agents-api/internal/sandbox/docker/provider.go b/services/agents-api/internal/sandbox/docker/provider.go index 4983ca25c..589443553 100644 --- a/services/agents-api/internal/sandbox/docker/provider.go +++ b/services/agents-api/internal/sandbox/docker/provider.go @@ -35,7 +35,7 @@ type Provider struct { config Config } -var _ sandbox.Provider = (*Provider)(nil) +var _ sandbox.SandboxProvider = (*Provider)(nil) func New(c *client.Client, config Config) (*Provider, error) { if c == nil || !validID(config.InstallationID) || (!strings.HasPrefix(config.Image, "sha256:") && !strings.Contains(config.Image, "@sha256:")) || config.Seccomp == "" || config.Network == "" || config.Network == "host" || strings.HasPrefix(config.Network, "container:") { diff --git a/services/agents-api/internal/sandbox/docker/provider_test.go b/services/agents-api/internal/sandbox/docker/provider_test.go index e4f6be5ea..000b6caf2 100644 --- a/services/agents-api/internal/sandbox/docker/provider_test.go +++ b/services/agents-api/internal/sandbox/docker/provider_test.go @@ -14,6 +14,7 @@ import ( "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/runtimeobs" "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/containerd/errdefs" "github.com/google/uuid" "github.com/moby/moby/client" @@ -107,12 +108,7 @@ func TestDockerProviderLifecycle(t *testing.T) { } }) info, e := p.Create(ctx, b) - if e != nil { - t.Fatal(e) - } - if info.State != "running" || info.ProviderID == "" { - t.Fatalf("bad compute observation: %+v", info) - } + contracttest.AssertObservation(t, info, e, b.Reference, "", "running") resources, e := p.Observe(ctx, runtimeobs.Target{ TenantID: b.TenantID, SessionID: b.SessionID, EnvironmentID: b.EnvironmentID, Mode: runtimeobs.ModeManaged, Instance: runtimeobs.Instance{AllocationID: b.AllocationID, ProviderKey: installationID, DeviceID: b.DeviceID}, diff --git a/services/agents-api/internal/sandbox/e2b/contract_test.go b/services/agents-api/internal/sandbox/e2b/contract_test.go new file mode 100644 index 000000000..f4d55e827 --- /dev/null +++ b/services/agents-api/internal/sandbox/e2b/contract_test.go @@ -0,0 +1,57 @@ +package e2b + +import ( + "context" + "errors" + "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) + +func (f contractCaller) Call(ctx context.Context, q Request) (Response, error) { return f(ctx, q) } + +func TestProviderContract(t *testing.T) { + contracttest.RunFailures(t, func(t *testing.T, s contracttest.Scenario, cancel context.CancelFunc) contracttest.Fixture { + p, _, r := fixture(t) + b := sandbox.Bootstrap{Reference: r, SessionID: uuid.NewString(), DeviceID: uuid.NewString(), CoreURL: "https://core.example/api/v1", Credential: "synthetic", NetworkAccess: "enabled"} + var calls []string + p.caller = contractCaller(func(ctx context.Context, q Request) (Response, error) { + calls = append(calls, q.Operation) + if q.Reference != r { + t.Fatal("native request lost allocation identity") + } + switch s.Fault { + case contracttest.Canceled: + cancel() + return Response{}, ctx.Err() + case contracttest.ForeignOwnership: + if s.Operation == "kill" { + return Response{Version: ProtocolVersion, ErrorCode: "ownership"}, nil + } + foreign := r + foreign.AllocationID = uuid.NewString() + return Response{Version: ProtocolVersion, Info: &sandbox.Info{Reference: foreign, ProviderID: "foreign", State: "running", CreateSettled: true, BootstrapComplete: true}}, nil + case contracttest.CleanupFailure: + return Response{Version: ProtocolVersion, ErrorCode: "unconfirmed"}, nil + default: + return Response{}, errors.New("lost helper response") + } + }) + return contracttest.Fixture{Provider: p, Bootstrap: b, Calls: func() []string { return calls }, WantCalls: []string{s.Operation}} + }) +} + +func TestProviderContractObservation(t *testing.T) { + p, f, r := fixture(t) + f.response.Info = &sandbox.Info{Reference: r, ProviderID: "native-owned", State: "running", CreateSettled: true, BootstrapComplete: true} + b := sandbox.Bootstrap{Reference: r, SessionID: uuid.NewString(), DeviceID: uuid.NewString(), CoreURL: "https://core.example/api/v1", Credential: "synthetic", NetworkAccess: "enabled"} + got, err := p.Create(bounded(t), b) + contracttest.AssertObservation(t, got, err, r, "native-owned", "running") + got, err = p.GetInfo(bounded(t), r) + contracttest.AssertObservation(t, got, err, r, "native-owned", "running") + got, err = p.Renew(bounded(t), r) + contracttest.AssertObservation(t, got, err, r, "native-owned", "running") +} diff --git a/services/agents-api/internal/sandbox/e2b/provider.go b/services/agents-api/internal/sandbox/e2b/provider.go index e1ec26507..d6865e284 100644 --- a/services/agents-api/internal/sandbox/e2b/provider.go +++ b/services/agents-api/internal/sandbox/e2b/provider.go @@ -67,7 +67,7 @@ type Provider struct { now func() time.Time } -var _ sandbox.Provider = (*Provider)(nil) +var _ sandbox.SandboxProvider = (*Provider)(nil) func validID(value string) bool { id, err := uuid.Parse(value) diff --git a/services/agents-api/internal/sandbox/microsandbox/contract_test.go b/services/agents-api/internal/sandbox/microsandbox/contract_test.go new file mode 100644 index 000000000..3a11cb15d --- /dev/null +++ b/services/agents-api/internal/sandbox/microsandbox/contract_test.go @@ -0,0 +1,72 @@ +package microsandbox + +import ( + "context" + "errors" + "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) { + contracttest.RunFailures(t, func(t *testing.T, s contracttest.Scenario, cancel context.CancelFunc) contracttest.Fixture { + c, r := testConfig(), testRef() + b := sandbox.Bootstrap{Reference: r, SessionID: uuid.NewString(), DeviceID: uuid.NewString(), CoreURL: "https://core.example/api/v1", Credential: "synthetic", NetworkAccess: "enabled"} + var calls []string + p, err := NewWithCaller(c, callerFunc(func(ctx context.Context, q Request) (Response, error) { + calls = append(calls, q.Operation) + if q.Reference != r { + t.Fatal("native request lost allocation identity") + } + switch s.Fault { + case contracttest.Canceled: + cancel() + return Response{}, ctx.Err() + case contracttest.ForeignOwnership: + if s.Operation == "kill" { + return Response{Version: ProtocolVersion, ErrorCode: "ownership"}, nil + } + foreign := r + foreign.AllocationID = uuid.NewString() + return Response{Version: ProtocolVersion, State: &State{Compute: Compute{Name: Name(c, foreign, 0), ID: "foreign"}, Status: "running", BootstrapComplete: true}}, nil + case contracttest.CleanupFailure: + return Response{Version: ProtocolVersion, ErrorCode: "unconfirmed"}, nil + default: + return Response{}, errors.New("lost helper response") + } + })) + if err != nil { + t.Fatal(err) + } + nativeOperation := s.Operation + if nativeOperation == "renew" { + nativeOperation = "inspect" + } + return contracttest.Fixture{Provider: p, Bootstrap: b, Calls: func() []string { return calls }, WantCalls: []string{nativeOperation}} + }) +} + +func TestProviderContractObservation(t *testing.T) { + c, r := testConfig(), testRef() + p, err := NewWithCaller(c, callerFunc(func(_ context.Context, q Request) (Response, error) { + if q.Operation != "inspect" && q.Operation != "create" { + t.Fatal("observation mutated compute") + } + status := "stopped" + if q.Operation == "create" { + status = "running" + } + return Response{Version: ProtocolVersion, State: &State{Compute: Compute{Name: Name(c, r, 0), ID: "native-owned"}, Status: status}}, nil + })) + if err != nil { + t.Fatal(err) + } + b := sandbox.Bootstrap{Reference: r, SessionID: uuid.NewString(), DeviceID: uuid.NewString(), CoreURL: "https://core.example/api/v1", Credential: "synthetic", NetworkAccess: "enabled"} + got, err := p.Create(deadline(t), b) + contracttest.AssertObservation(t, got, err, r, "native-owned", "running") + got, err = p.GetInfo(deadline(t), r) + contracttest.AssertObservation(t, got, err, r, "native-owned", "stopped") + got, err = p.Renew(deadline(t), r) + contracttest.AssertObservation(t, got, err, r, "native-owned", "stopped") +} diff --git a/services/agents-api/internal/sandbox/microsandbox/provider.go b/services/agents-api/internal/sandbox/microsandbox/provider.go index 25439cad7..4e787cbd7 100644 --- a/services/agents-api/internal/sandbox/microsandbox/provider.go +++ b/services/agents-api/internal/sandbox/microsandbox/provider.go @@ -15,7 +15,7 @@ type Provider struct { caller Caller } -var _ sandbox.Provider = (*Provider)(nil) +var _ sandbox.SandboxProvider = (*Provider)(nil) var _ sandbox.CheckpointProvider = (*Provider)(nil) func New(c Config) (*Provider, error) { return NewWithCaller(c, &ProcessCaller{}) } diff --git a/services/agents-api/internal/sandbox/node/agent.go b/services/agents-api/internal/sandbox/node/agent.go index dd6fff58a..a570a01fa 100644 --- a/services/agents-api/internal/sandbox/node/agent.go +++ b/services/agents-api/internal/sandbox/node/agent.go @@ -22,7 +22,7 @@ type AgentConfig struct { StateDirectory string Identity Identity Credential string - Provider sandbox.Provider + Provider sandbox.SandboxProvider Probe func(context.Context) (Health, error) // Dialer is optional, primarily for an operator-supplied TLS trust configuration. Dialer *websocket.Dialer @@ -68,7 +68,7 @@ func (c *agentConnection) write(f frame) error { type work struct { request request connection *agentConnection - provider sandbox.Provider + provider sandbox.SandboxProvider release func() ready bool } diff --git a/services/agents-api/internal/sandbox/node/connection_test.go b/services/agents-api/internal/sandbox/node/connection_test.go index 5d6cedddc..a1da83978 100644 --- a/services/agents-api/internal/sandbox/node/connection_test.go +++ b/services/agents-api/internal/sandbox/node/connection_test.go @@ -152,7 +152,7 @@ func TestCopiedIdentityCannotReplaceNodeWithInflightCreate(t *testing.T) { duplicate := &fakeProvider{} var release sync.Once defer release.Do(func() { close(original.release) }) - start := func(dir string, p sandbox.Provider) (context.CancelFunc, chan error) { + start := func(dir string, p sandbox.SandboxProvider) (context.CancelFunc, chan error) { ctx, cancel := context.WithCancel(context.Background()) done := make(chan error, 1) go func() { diff --git a/services/agents-api/internal/sandbox/node/generations.go b/services/agents-api/internal/sandbox/node/generations.go index 57b45ba85..4fa625845 100644 --- a/services/agents-api/internal/sandbox/node/generations.go +++ b/services/agents-api/internal/sandbox/node/generations.go @@ -13,7 +13,7 @@ import ( type GenerationProvider struct { Generation uint64 SpecificationDigest string - Provider sandbox.Provider + Provider sandbox.SandboxProvider Probe func(context.Context) error Close func() } @@ -187,7 +187,7 @@ func (m *GenerationManager) Retained(after uint64) []sandbox.GenerationReference } // Acquire is called before queue admission, so queued work also prevents GC. -func (m *GenerationManager) Acquire(generation uint64) (sandbox.Provider, bool, func(), error) { +func (m *GenerationManager) Acquire(generation uint64) (sandbox.SandboxProvider, bool, func(), error) { m.mu.Lock() defer m.mu.Unlock() g := m.values[generation] diff --git a/services/agents-api/internal/sandbox/node/observations.go b/services/agents-api/internal/sandbox/node/observations.go index db52c2865..c63c3215c 100644 --- a/services/agents-api/internal/sandbox/node/observations.go +++ b/services/agents-api/internal/sandbox/node/observations.go @@ -39,7 +39,7 @@ func (p *provider) Observe(ctx context.Context, target runtimeobs.Target) (runti return *out.Sample, nil } -func observeProvider(ctx context.Context, provider sandbox.Provider, target runtimeobs.Target) (runtimeobs.Sample, error) { +func observeProvider(ctx context.Context, provider sandbox.SandboxProvider, target runtimeobs.Target) (runtimeobs.Sample, error) { source, ok := provider.(runtimeobs.Source) if !ok { return runtimeobs.Sample{}, runtimeobs.ErrUnavailable diff --git a/services/agents-api/internal/sandbox/node/observations_test.go b/services/agents-api/internal/sandbox/node/observations_test.go index bbbcecb97..547d4f7f5 100644 --- a/services/agents-api/internal/sandbox/node/observations_test.go +++ b/services/agents-api/internal/sandbox/node/observations_test.go @@ -37,7 +37,7 @@ func observationTarget(r sandbox.Reference, installation string) runtimeobs.Targ Instance: runtimeobs.Instance{AllocationID: r.AllocationID, ProviderKey: installation, AllocationState: "running", ComputePhase: "running", ProviderState: json.RawMessage(`{"current":{"id":"exact-incarnation","generation":3}}`)}, TokenUsage: &runtimeobs.TokenUsage{InputTokens: 123, OutputTokens: 456}} } -func runObservationNode(t *testing.T, hub *Hub, url string, id Identity, provider sandbox.Provider) context.CancelFunc { +func runObservationNode(t *testing.T, hub *Hub, url string, id Identity, provider sandbox.SandboxProvider) context.CancelFunc { t.Helper() dir := stateDir(t) stored, err := InitIdentity(dir, url, id) @@ -140,7 +140,7 @@ func TestObservationWirePreservesUnavailableAndRejectsMismatchedIdentity(t *test q := request{ID: uuid.NewString(), ConnectionID: uuid.NewString(), Operation: "observe", Reference: r, Observation: &target, TimeoutMillis: 1000} providers := []struct { name string - provider sandbox.Provider + provider sandbox.SandboxProvider want error }{ {"unsupported", &fakeProvider{}, runtimeobs.ErrUnavailable}, diff --git a/services/agents-api/internal/sandbox/node/proxy.go b/services/agents-api/internal/sandbox/node/proxy.go index ba969a5d4..83c159306 100644 --- a/services/agents-api/internal/sandbox/node/proxy.go +++ b/services/agents-api/internal/sandbox/node/proxy.go @@ -13,18 +13,18 @@ type provider struct { } type checkpointProvider struct{ *provider } -var _ sandbox.Provider = (*provider)(nil) +var _ sandbox.SandboxProvider = (*provider)(nil) var _ sandbox.CheckpointProvider = (*checkpointProvider)(nil) // Provider advertises checkpoint support only for a deployment using microsandbox. -func (h *Hub) Provider(kind string, resolve Resolver) sandbox.Provider { +func (h *Hub) Provider(kind string, resolve Resolver) sandbox.SandboxProvider { p := &provider{hub: h, resolve: resolve, kind: kind} if kind == "microsandbox" { return &checkpointProvider{p} } return p } -func (h *Hub) Proxy(id, kind string) sandbox.Provider { +func (h *Hub) Proxy(id, kind string) sandbox.SandboxProvider { return h.Provider(kind, func(context.Context, sandbox.Reference) (string, error) { return id, nil }) } func (p *provider) call(ctx context.Context, q request) (response, error) { @@ -150,7 +150,7 @@ func (p *checkpointProvider) ResumeCompute(ctx context.Context, r sandbox.Refere // GenerationProvider routes every operation with allocation-owned generation, // distinct from the request's compute generation. -func (h *Hub) GenerationProvider(kind string, resolve func(context.Context, sandbox.Reference) (string, uint64, error)) sandbox.Provider { +func (h *Hub) GenerationProvider(kind string, resolve func(context.Context, sandbox.Reference) (string, uint64, error)) sandbox.SandboxProvider { p := &provider{hub: h, kind: kind, resolveGeneration: resolve} if kind == "microsandbox" { return &checkpointProvider{p} diff --git a/services/agents-api/internal/sandbox/node/wire.go b/services/agents-api/internal/sandbox/node/wire.go index 8fc7b2c39..e04a719ba 100644 --- a/services/agents-api/internal/sandbox/node/wire.go +++ b/services/agents-api/internal/sandbox/node/wire.go @@ -271,7 +271,7 @@ func (q request) validate() error { } return sandbox.ErrInvalid } -func execute(ctx context.Context, p sandbox.Provider, q request) response { +func execute(ctx context.Context, p sandbox.SandboxProvider, q request) response { out := response{ID: q.ID, ConnectionID: q.ConnectionID} err := q.validate() if err != nil { diff --git a/services/agents-api/internal/sandbox/provider.go b/services/agents-api/internal/sandbox/provider.go deleted file mode 100644 index f5a69b770..000000000 --- a/services/agents-api/internal/sandbox/provider.go +++ /dev/null @@ -1,63 +0,0 @@ -// Package sandbox manages compute for an already authorized Environment. -// It does not schedule Turns or implement routine execution and Files operations. -package sandbox - -import ( - "context" - "errors" -) - -var ( - ErrInvalid = errors.New("invalid sandbox configuration") - ErrOwnership = errors.New("sandbox ownership mismatch") - ErrExists = errors.New("sandbox allocation already exists") - ErrNotFound = errors.New("sandbox allocation not found") - ErrCommandUnconfirmed = errors.New("initialization command outcome unconfirmed; reclaim allocation before reuse") -) - -// Reference must be persisted by the caller before Create. AllocationID is a fresh -// UUID for one attempt, not the Environment ID. Serialize lifecycle operations for -// an allocation; a lost Create response is resolved with GetInfo, never by replay. -type Reference struct{ TenantID, EnvironmentID, AllocationID string } - -type Bootstrap struct { - Reference - SessionID, DeviceID, CoreURL, Credential string - NetworkAccess string - AllowedDomains []string -} - -// Info describes compute only. Running does not establish daemon authentication, -// native preparation, Environment readiness or a renewable provider lease. -type Info struct { - Reference - ProviderID, State string - // BootstrapComplete is provider evidence that initialization has reached its - // last mutating step. It does not establish daemon or native readiness. - BootstrapComplete bool - // CreateSettled proves that the original create and initialization attempt can - // no longer mutate resources. An absent observation needs this explicit proof; - // an ordinary missing resource or empty provider listing is not sufficient. - CreateSettled bool -} -type Command struct { - Args []string - Directory string - // Stdin carries confidential initialization bytes without exposing them in argv. - Stdin []byte -} - -const MaxCommandInputBytes = 50*1024*1024 + 32 - -type CommandResult struct { - Stdout, Stderr string - ExitCode int -} - -type Provider interface { - Create(context.Context, Bootstrap) (Info, error) - GetInfo(context.Context, Reference) (Info, error) - Renew(context.Context, Reference) (Info, error) - Kill(context.Context, Reference) error - RunCommand(context.Context, Reference, Command) (CommandResult, error) -} diff --git a/services/agents-api/internal/sandbox/sandbox_provider.go b/services/agents-api/internal/sandbox/sandbox_provider.go new file mode 100644 index 000000000..019d3722a --- /dev/null +++ b/services/agents-api/internal/sandbox/sandbox_provider.go @@ -0,0 +1,106 @@ +// Package sandbox defines the Sandbox Provider contract for authorized compute. +// Start at sandbox_provider.go and docs/sandbox-provider.md when adding an adapter. +// Core owns durable Environment/allocation state and lifecycle serialization; +// SandboxProvider owns compute and bootstrap, Runtime owns capability preparation, +// and Harness adapters own native execution. Compute running is not execution ready. +// +// Required operations are on SandboxProvider. CheckpointProvider is an independent +// optional capability; runtimeobs.Source and runtimeobs.BatchSource define optional +// read-only observation. Implementing an interface is a capability claim, not proof +// of qualification: run the common contract tests and adapter-specific acceptance. +// +// Registration is explicit construction, not a global init-time registry. Node-local +// adapters register in sandbox/config.Build; Core's cmd/server managedSetup.provider +// constructs direct adapters or node proxies. execution.RuntimeProvider binds the +// selected adapter to installation, backend, deployment generation and node identity. +// Keep vendor configuration at those construction boundaries; common lifecycle code +// selects behavior through these contracts, never through a vendor name. +package sandbox + +import ( + "context" + "errors" +) + +var ( + ErrInvalid = errors.New("invalid sandbox configuration") + ErrOwnership = errors.New("sandbox ownership mismatch") + ErrExists = errors.New("sandbox allocation already exists") + ErrNotFound = errors.New("sandbox allocation not found") + ErrCommandUnconfirmed = errors.New("initialization command outcome unconfirmed; reclaim allocation before reuse") +) + +// Reference must be persisted by the caller before Create. AllocationID is a fresh +// UUID for one attempt, not the Environment ID. Serialize lifecycle operations for +// an allocation; a lost Create response is resolved with GetInfo, never by replay. +type Reference struct{ TenantID, EnvironmentID, AllocationID string } + +type Bootstrap struct { + Reference + SessionID, DeviceID, CoreURL, Credential string + NetworkAccess string + AllowedDomains []string +} + +// Info describes compute only. Running does not establish daemon authentication, +// native preparation, Environment readiness or a renewable provider lease. +type Info struct { + Reference + ProviderID, State string + // BootstrapComplete is provider evidence that initialization has reached its + // last mutating step. It does not establish daemon or native readiness. + BootstrapComplete bool + // CreateSettled proves that the original create and initialization attempt can + // no longer mutate resources. An absent observation needs this explicit proof; + // an ordinary missing resource or empty provider listing is not sufficient. + CreateSettled bool +} +type Command struct { + Args []string + Directory string + // Stdin carries confidential initialization bytes without exposing them in argv. + Stdin []byte +} + +const MaxCommandInputBytes = 50*1024*1024 + 32 + +type CommandResult struct { + Stdout, Stderr string + ExitCode int +} + +// SandboxProvider manages one persisted Reference at a time. Every call has a +// bounded context; cancellation ends the caller's wait, not proof of native stop. +// Non-nil errors retain ownership, including partial results. Do not retry Create +// or RunCommand after unknown delivery; observe/reclaim the original Reference. +// Implementations verify installation plus Reference ownership before mutation. +// See docs/sandbox-provider.md for settlement, cleanup and retry requirements. +type SandboxProvider interface { + // Create performs the original attempt once; no credential overwrite on conflict. + Create(context.Context, Bootstrap) (Info, error) + // GetInfo observes compute without creating, starting, renewing or preparing it. + GetInfo(context.Context, Reference) (Info, error) + // Renew extends an existing native lease where supported; otherwise it observes. + // It never revives stopped compute or establishes execution readiness. + Renew(context.Context, Reference) (Info, error) + // Kill confirms removal of owned compute and retained resources. Absence is + // idempotent, but nil alone cannot settle an outstanding Create. + Kill(context.Context, Reference) error + RunCommand(context.Context, Reference, Command) (CommandResult, error) +} + +// CheckpointProvider is optional. It supplements the existing provider with +// exact-incarnation operations; Worker and Store remain the lifecycle owner. +type CheckpointProvider interface { + SandboxProvider + Initial(context.Context, Reference) (Compute, error) + NewCompute(context.Context, Reference, uint64, *SnapshotIdentity) (Compute, error) + GetCompute(context.Context, Reference, Compute) (ComputeState, error) + Suspend(context.Context, SuspendRequest) (ComputeState, error) + Resume(context.Context, ResumeRequest) (ComputeState, error) + KillCompute(context.Context, Reference, Compute) error + DeleteSnapshot(context.Context, Reference, SnapshotIdentity) error + RunCommandCompute(context.Context, Reference, Compute, Command) (CommandResult, error) + // ResumeCompute thaws only the same resident instance after an aborted pause. + ResumeCompute(context.Context, Reference, Compute) (ComputeState, error) +} diff --git a/services/agents-api/internal/sandbox/suspension.go b/services/agents-api/internal/sandbox/suspension.go index 80f694bca..5a600a877 100644 --- a/services/agents-api/internal/sandbox/suspension.go +++ b/services/agents-api/internal/sandbox/suspension.go @@ -1,7 +1,6 @@ package sandbox import ( - "context" "errors" ) @@ -56,19 +55,3 @@ type ResumeRequest struct { // Recovery observes the previous target and never starts a new restore. ObserveOnly bool } - -// CheckpointProvider is optional. It supplements the existing provider with -// exact-incarnation operations; Worker and Store remain the lifecycle owner. -type CheckpointProvider interface { - Provider - Initial(context.Context, Reference) (Compute, error) - NewCompute(context.Context, Reference, uint64, *SnapshotIdentity) (Compute, error) - GetCompute(context.Context, Reference, Compute) (ComputeState, error) - Suspend(context.Context, SuspendRequest) (ComputeState, error) - Resume(context.Context, ResumeRequest) (ComputeState, error) - KillCompute(context.Context, Reference, Compute) error - DeleteSnapshot(context.Context, Reference, SnapshotIdentity) error - RunCommandCompute(context.Context, Reference, Compute, Command) (CommandResult, error) - // ResumeCompute thaws only the same resident instance after an aborted pause. - ResumeCompute(context.Context, Reference, Compute) (ComputeState, error) -} diff --git a/services/agents-api/internal/store/runtime_lifecycle_test.go b/services/agents-api/internal/store/runtime_lifecycle_test.go index 37d00da64..45ee941e1 100644 --- a/services/agents-api/internal/store/runtime_lifecycle_test.go +++ b/services/agents-api/internal/store/runtime_lifecycle_test.go @@ -71,12 +71,12 @@ func (p *lifecycleProvider) RunCommand(context.Context, sandbox.Reference, sandb return sandbox.CommandResult{}, errors.New("not used") } -func managedWorker(t *testing.T, s *store.Store, key string, p sandbox.Provider) (*execution.Worker, func()) { +func managedWorker(t *testing.T, s *store.Store, key string, p sandbox.SandboxProvider) (*execution.Worker, func()) { t.Helper() return managedWorkerMode(t, s, key, p, false) } -func managedWorkerMode(t *testing.T, s *store.Store, key string, p sandbox.Provider, maintenance bool) (*execution.Worker, func()) { +func managedWorkerMode(t *testing.T, s *store.Store, key string, p sandbox.SandboxProvider, maintenance bool) (*execution.Worker, func()) { t.Helper() registry := gateway.NewRegistry() if peer, ok := p.(interface { From f441745dd19e14348b0232008a6c2d0597ff1339 Mon Sep 17 00:00:00 2001 From: saladday <1203511142@qq.com> Date: Mon, 28 Sep 2026 18:02:15 +0000 Subject: [PATCH 2/2] Measure allocation creation before background admission --- .../agents-api/internal/store/runtime_node_lifecycle_test.go | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/services/agents-api/internal/store/runtime_node_lifecycle_test.go b/services/agents-api/internal/store/runtime_node_lifecycle_test.go index 3ba6bdfa6..1fc086b7f 100644 --- a/services/agents-api/internal/store/runtime_node_lifecycle_test.go +++ b/services/agents-api/internal/store/runtime_node_lifecycle_test.go @@ -54,10 +54,12 @@ func TestManagedNodesIsolateBlockedProviderAndInitialization(t *testing.T) { // Same-node direct callers and the scan all consume the same one-shot // allocation receipt. A's provider or Runtime operation remains blocked. - tenant, _, env := f.session(f.nodeB, true) + // The background scan can provision as soon as the Session is saved. + // Capture the baseline before making the new Environment visible. f.provider.mu.Lock() before := f.provider.creates f.provider.mu.Unlock() + tenant, _, env := f.session(f.nodeB, true) var callers sync.WaitGroup results := make(chan store.RuntimeAllocation, 12) failures := make(chan error, 12)