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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion docs/sandbox-provider.md
Original file line number Diff line number Diff line change
Expand Up @@ -254,7 +254,7 @@ A Provider relies on these rules:

### Checkpoint transfer

A checkpoint-capable generation reports `CheckpointCompatibility`: opaque `ArtifactDomain` and `ExecutionClass` tokens. Every verified `SnapshotIdentity` carries the same qualification. Core compares these values without interpreting CPU features, filesystem paths or storage implementations. A restore destination must be online and ready for the allocation's immutable deployment generation, match both tokens, and have active capacity plus a retained slot when moving from another node. The allocation, Device and Session identities remain unchanged. The Session and deployment transaction commits the destination route, placement and exact restore intent before target-side native work. No capacity or compatible destination leaves the retained snapshot owned until its configured deadline. The target must already hold that exact generation; Core does not automatically prepare historical generations on new nodes. A node keeps a superseded generation's provider until its checkpoint retention obligations end.
A checkpoint-capable generation reports `CheckpointCompatibility`: opaque `ArtifactDomain` and `ExecutionClass` tokens. Every verified `SnapshotIdentity` carries the same qualification. It also carries an opaque `Evidence` of at most 4096 bytes that only the Provider interprets; Core and the node proxy store and return it unchanged. Core compares these values without interpreting CPU features, filesystem paths or storage implementations. A restore destination must be online and ready for the allocation's immutable deployment generation, match both tokens, and have active capacity plus a retained slot when moving from another node. The allocation, Device and Session identities remain unchanged. The Session and deployment transaction commits the destination route, placement and exact restore intent before target-side native work. No capacity or compatible destination leaves the retained snapshot owned until its configured deadline. The target must already hold that exact generation; Core does not automatically prepare historical generations on new nodes. A node keeps a superseded generation's provider until its checkpoint retention obligations end.

`Suspend` may report `SourceStopped` only after publishing a verified full checkpoint durably, stopping the exact source compute and settling source-local capture and cleanup obligations. Core persists the snapshot before marking it suspended; native absence alone is insufficient. `Suspend.ObserveOnly` may finish checkpoint publication and source cleanup for an already verified snapshot of the same operation, but cannot capture another snapshot or stop a source that has resumed running. If a previously published checkpoint is missing or damaged, `ObserveOnly` may return its exact durable ownership receipt with `Status: unknown` and `SourceStopped: false` for cleanup; that receipt proves neither current checkpoint usability nor source absence. `Resume` still verifies the checkpoint before execution. After the source-settlement barrier, source-node unavailability does not prevent restoration. Deletion and ordinary expiry can transfer an idle suspended allocation's cleanup route to an online node in the same artifact domain; deletion does not require execution-class compatibility. The cleanup destination must already serve the same immutable generation and have retained capacity. Without such a node, cleanup stays pending and ownership is not released; operators must restore an eligible node or its capacity. An unknown running or restoring writer never moves merely because its node is unreachable.

Expand Down
4 changes: 2 additions & 2 deletions docs/zh/sandbox-provider.md
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
---
title: "沙箱提供商协议"
source: docs/sandbox-provider.md
source_hash: 0ffc892ad458e104e5e9eba3427429b3f82e7376bfdf3eeac0c9653cee434db1
source_hash: 590c360468ffa40fef877d3ef5cb2b9ee7f213d7e2aeb2ad28df515d965629d4
---

沙箱提供商协议连接 Core 与为 Core 管理的(`openai_hosted`)Environment 提供计算资源的 adapter,并由该 adapter 运行启动 Runtime daemon 的有界引导流程。本页面向 adapter 作者:说明提供商必须实现什么,以及可以依赖 Core 做什么。Docker、microsandbox 和 E2B 实现了该协议。
Expand Down Expand Up @@ -256,7 +256,7 @@ Core 在初始化完成、没有 root 或 Subagent Turn 处于排队、进行中

### 检查点转移 {#checkpoint-transfer}

支持检查点的 generation 报告 `CheckpointCompatibility`,包含不透明的 `ArtifactDomain` 与 `ExecutionClass` token。每个已验证 `SnapshotIdentity` 携带同样的资格。Core 只比较这些值,不解释 CPU 特性、文件系统路径或存储实现。恢复目标必须在线、对 allocation 的不可变 deployment generation 已就绪、两个 token 均匹配,并有 active 容量;跨 node 时还需 retained slot。allocation、Device 和 Session 身份保持不变。Session 与 deployment 事务先提交目标路由、placement 和精确 restore intent,再执行目标侧原生操作。没有容量或兼容目标时,保留快照的所有权一直持续到配置期限。目标必须已持有该精确 generation;Core 不会在新节点自动准备历史 generation。节点会保留被取代的 generation 的提供商,直到检查点保留义务结束。
支持检查点的 generation 报告 `CheckpointCompatibility`,包含不透明的 `ArtifactDomain` 与 `ExecutionClass` token。每个已验证 `SnapshotIdentity` 携带同样的资格。它还携带最长 4096 字节的不透明 `Evidence`,只有提供商解释它;Core 与 node 代理原样存储并返回。Core 只比较这些值,不解释 CPU 特性、文件系统路径或存储实现。恢复目标必须在线、对 allocation 的不可变 deployment generation 已就绪、两个 token 均匹配,并有 active 容量;跨 node 时还需 retained slot。allocation、Device 和 Session 身份保持不变。Session 与 deployment 事务先提交目标路由、placement 和精确 restore intent,再执行目标侧原生操作。没有容量或兼容目标时,保留快照的所有权一直持续到配置期限。目标必须已持有该精确 generation;Core 不会在新节点自动准备历史 generation。节点会保留被取代的 generation 的提供商,直到检查点保留义务结束。

`Suspend` 只有在持久发布已验证的完整检查点、停止精确 source compute,并结清 source 本地 capture 与 cleanup 义务后,才能报告 `SourceStopped`。Core 先持久化快照,再标记 suspended;仅原生资源不存在并不足够。`Suspend.ObserveOnly` 可以为同一 operation 已验证的快照完成检查点发布和源清理,但不能重新捕获快照,也不能停止已经恢复 running 的源。此前已发布的检查点缺失或损坏时,`ObserveOnly` 可以返回其精确持久 ownership receipt,标记 `Status: unknown` 和 `SourceStopped: false`,供清理使用;该 receipt 不证明检查点当前可用,也不证明源不存在。`Resume` 仍在执行前验证检查点。完成源结清屏障后,source node 不可用不再阻止恢复。删除和正常到期可以将空闲 suspended allocation 的清理路由转给同一 artifact domain 内的在线 node;删除不要求 execution class 匹配。清理目标必须已提供同一不可变 generation,且有 retained 容量。没有这样的 node 时,清理保持 pending,不释放所有权;运维需恢复符合条件的 node 或其容量。未知 running 或 restoring writer 不会仅因 node 不可达而迁移。

Expand Down
4 changes: 2 additions & 2 deletions services/core/cmd/server/managed_generations_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ q=json.load(sys.stdin)
with (pathlib.Path(q['Config']['StateDir'])/'requests').open('a') as f: f.write(json.dumps(q)+'\n')
info=dict(q['Reference'],State='running',ProviderID='owned',CreateSettled=True)
if q['Operation']=='kill': info['State']='absent'
print(json.dumps({'Version':1,'Info':info}))
print(json.dumps({'Version':2,'Info':info}))
`
if err := os.WriteFile(helper, []byte(script), 0700); err != nil {
t.Fatal(err)
Expand Down Expand Up @@ -132,7 +132,7 @@ code = ''
if k == 'revoked': code = 'unauthorized'
elif k == 'unconfirmed': code = 'unconfirmed'
elif not (k.startswith('team-a') and template in ('owned-a', 'new-a') or k == 'team-b' and template == 'public-b'): code = 'team_mismatch'
result = {'Version': 1, 'ErrorCode': code}
result = {'Version': 2, 'ErrorCode': code}
if not code:
result['DeploymentValid'] = True
if q['Operation'] == 'validate_deployment':
Expand Down
10 changes: 5 additions & 5 deletions services/core/cmd/server/managed_setup_preflight_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ import (

func TestE2BRejectedSpecificationHasSafeActionableDiagnostic(t *testing.T) {
helper := filepath.Join(t.TempDir(), "provider")
if err := os.WriteFile(helper, []byte("#!/bin/sh\ncat >/dev/null\nprintf '%s' '{\"Version\":1,\"ErrorCode\":\"invalid\"}'\n"), 0700); err != nil {
if err := os.WriteFile(helper, []byte("#!/bin/sh\ncat >/dev/null\nprintf '%s' '{\"Version\":2,\"ErrorCode\":\"invalid\"}'\n"), 0700); err != nil {
t.Fatal(err)
}
state := filepath.Join(t.TempDir(), "e2b")
Expand Down Expand Up @@ -49,12 +49,12 @@ op = q['Operation']
with (pathlib.Path(q['Config']['StateDir']) / 'operations').open('a') as log:
log.write(op + '\n')
if op == 'validate_deployment':
print(json.dumps({'Version': 1, 'ErrorCode': 'invalid'}))
print(json.dumps({'Version': 2, 'ErrorCode': 'invalid'}))
else:
info = dict(q['Reference'], ProviderID='owned-compute', State='stopped', CreateSettled=True)
if op == 'kill':
info.update(ProviderID='', State='absent')
print(json.dumps({'Version': 1, 'Info': info}))
print(json.dumps({'Version': 2, 'Info': info}))
`
if err := os.WriteFile(helper, []byte(script), 0700); err != nil {
t.Fatal(err)
Expand Down Expand Up @@ -101,7 +101,7 @@ func TestE2BCandidateAdoptsTemplateBuildForOmittedResources(t *testing.T) {
}
helper := filepath.Join(t.TempDir(), "provider")
requests := filepath.Join(state, "requests")
script := "#!/bin/sh\ncat >>" + requests + "\necho >>" + requests + "\nprintf '%s' '{\"Version\":1,\"ErrorCode\":\"\",\"DeploymentValid\":true,\"TemplateBuild\":{\"Status\":\"ready\",\"CPUs\":4,\"MemoryMiB\":4096,\"RootDiskMiB\":24063}}'\n"
script := "#!/bin/sh\ncat >>" + requests + "\necho >>" + requests + "\nprintf '%s' '{\"Version\":2,\"ErrorCode\":\"\",\"DeploymentValid\":true,\"TemplateBuild\":{\"Status\":\"ready\",\"CPUs\":4,\"MemoryMiB\":4096,\"RootDiskMiB\":24063}}'\n"
if err := os.WriteFile(helper, []byte(script), 0700); err != nil {
t.Fatal(err)
}
Expand Down Expand Up @@ -133,7 +133,7 @@ func TestE2BCandidateAdoptsTemplateBuildForOmittedResources(t *testing.T) {

func TestInitialE2BPublicTemplateOutsideTeamIsRejected(t *testing.T) {
helper := filepath.Join(t.TempDir(), "provider")
if err := os.WriteFile(helper, []byte("#!/bin/sh\ncat >/dev/null\nprintf '%s' '{\"Version\":1,\"ErrorCode\":\"team_mismatch\"}'\n"), 0700); err != nil {
if err := os.WriteFile(helper, []byte("#!/bin/sh\ncat >/dev/null\nprintf '%s' '{\"Version\":2,\"ErrorCode\":\"team_mismatch\"}'\n"), 0700); err != nil {
t.Fatal(err)
}
state := filepath.Join(t.TempDir(), "e2b")
Expand Down
2 changes: 1 addition & 1 deletion services/core/deploy/e2b/helper_contract_generated.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
MAX_REQUEST = 75497472
MAX_RESPONSE = 16777216
OPERATIONS = ["create","inspect","renew","kill","command","validate_deployment","observe","list_templates","list_builds","verify_credential","compute","renew_compute","suspend","resume","kill_compute","delete_snapshot","command_compute","resume_compute"]
PROTOCOL_VERSION = 1
PROTOCOL_VERSION = 2
REFERENCE_FIELDS = ["TenantID","EnvironmentID","AllocationID"]
REQUEST_FIELDS = ["Version","Operation","Config","Reference","References","Bootstrap","RuntimeBootstrap","Command","Compute","Suspend","Resume","Snapshot","Deadline"]
RESPONSE_FIELDS = ["Version","Info","Command","State","ErrorCode","DeploymentValid","TemplateBuild","Templates","Builds","Observation"]
Expand Down
4 changes: 2 additions & 2 deletions services/core/internal/execution/runtime_compute.go
Original file line number Diff line number Diff line change
Expand Up @@ -271,7 +271,7 @@ func (r *runtimeLifecycle) captureCompute(ctx context.Context, p sandbox.Sandbox
}
return r.wakeCompute(ctx, p, next, state)
}
if result.Snapshot.Compatibility.Validate() != nil {
if result.Snapshot.Validate() != nil {
return sandbox.ErrOwnership
}
state.Snapshot = result.Snapshot
Expand Down Expand Up @@ -345,7 +345,7 @@ func (r *runtimeLifecycle) expire(ctx context.Context, owner deployment.Allocati

func (r *runtimeLifecycle) restoreCompute(ctx context.Context, p sandbox.SandboxProvider, owner deployment.Allocation, state runtimeCompute, observeOnly bool) (err error) {
defer func() { err = withObservationOwner(owner, err) }()
if state.Snapshot == nil || state.Snapshot.Compatibility.Validate() != nil || state.Rollback || state.RestoreID == "" {
if state.Snapshot == nil || state.Snapshot.Validate() != nil || state.Rollback || state.RestoreID == "" {
return sandbox.ErrOwnership
}
if state.Target == nil {
Expand Down
2 changes: 1 addition & 1 deletion services/core/internal/sandbox/e2b/checkpoint.go
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ func (p *Provider) name(r sandbox.Reference, generation uint64) string {
}

func (p *Provider) validSnapshot(r sandbox.Reference, s sandbox.SnapshotIdentity) bool {
return s.Compatibility.Validate() == nil && validID(s.OperationID) && s.SourceName == p.name(r, s.SourceGeneration) &&
return s.Validate() == nil && validID(s.OperationID) && s.SourceName == p.name(r, s.SourceGeneration) &&
s.Reference == s.SourceName+":s-"+s.OperationID && s.SourceID != "" && s.ID != ""
}

Expand Down
2 changes: 1 addition & 1 deletion services/core/internal/sandbox/e2b/helper_contract.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ import (
//go:generate go run ./internal/contractgen

// This adapter-private boundary is documented in tools/e2b-provider/README.md.
const ProtocolVersion = 1
const ProtocolVersion = 2
const MaxOutputBytes = 1024 * 1024
const MaxRequestBytes = 72 * 1024 * 1024
const MaxResponseBytes = 16 * 1024 * 1024
Expand Down
2 changes: 1 addition & 1 deletion services/core/internal/sandbox/e2b/installed_paths_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ func TestConfigurationDiscoveryUsesSuppliedProcessPaths(t *testing.T) {
if err := os.MkdirAll(filepath.Dir(binary), 0700); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(binary, []byte("#!/bin/sh\ncat >/dev/null\nprintf '%s' '{\"Version\":1,\"Templates\":[]}'\n"), 0700); err != nil {
if err := os.WriteFile(binary, []byte("#!/bin/sh\ncat >/dev/null\nprintf '%s' '{\"Version\":2,\"Templates\":[]}'\n"), 0700); err != nil {
t.Fatal(err)
}
input := sandbox.ConfigurationDiscoveryInput{Credential: json.RawMessage(`{"api_key":"synthetic-key"}`)}
Expand Down
8 changes: 4 additions & 4 deletions services/core/internal/sandbox/e2b/process_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ import (
)

func TestProcessWireRejectsUnknownAndTrailingOutput(t *testing.T) {
for _, output := range []string{`{"Version":1,"Secret":"private"}`, `{"Version":1} {"Version":1}`} {
for _, output := range []string{`{"Version":2,"Secret":"private"}`, `{"Version":2} {"Version":2}`} {
root := t.TempDir()
binary := filepath.Join(root, "helper")
if err := os.WriteFile(binary, []byte("#!/bin/sh\nprintf '%s' '"+output+"'\n"), 0700); err != nil {
Expand All @@ -29,7 +29,7 @@ func TestTimeoutDoesNotTerminateOwnedHelper(t *testing.T) {
marker := filepath.Join(root, "settled")
// The private test path contains no shell syntax; the real adapter never puts
// credentials or request contents in argv or inherited environment.
script := "#!/bin/sh\nsleep 0.15\ntouch '" + marker + "'\nprintf '%s' '{\"Version\":1}'\n"
script := "#!/bin/sh\nsleep 0.15\ntouch '" + marker + "'\nprintf '%s' '{\"Version\":2}'\n"
if err := os.WriteFile(binary, []byte(script), 0700); err != nil {
t.Fatal(err)
}
Expand All @@ -50,7 +50,7 @@ func TestTimeoutDoesNotTerminateOwnedHelper(t *testing.T) {
func TestEnvironmentDropsProviderSelectorsAndCredentials(t *testing.T) {
root := t.TempDir()
binary := filepath.Join(root, "helper")
script := "#!/bin/sh\nif test -n \"${E2B_API_KEY:-}${E2B_API_URL:-}${PRIVATE_MODEL_KEY:-}\"; then exit 1; fi\nprintf '%s' '{\"Version\":1}'\n"
script := "#!/bin/sh\nif test -n \"${E2B_API_KEY:-}${E2B_API_URL:-}${PRIVATE_MODEL_KEY:-}\"; then exit 1; fi\nprintf '%s' '{\"Version\":2}'\n"
if err := os.WriteFile(binary, []byte(script), 0700); err != nil {
t.Fatal(err)
}
Expand All @@ -67,7 +67,7 @@ func TestCredentialFenceWaitsForActualHelperExitAfterCancellation(t *testing.T)
binary := filepath.Join(root, "helper")
marker := filepath.Join(root, "started")
finish := filepath.Join(root, "finish")
script := "#!/bin/sh\ntouch '" + marker + "'\nwhile ! test -f '" + finish + "'; do sleep 0.01; done\nprintf '%s' '{\"Version\":1}'\n"
script := "#!/bin/sh\ntouch '" + marker + "'\nwhile ! test -f '" + finish + "'; do sleep 0.01; done\nprintf '%s' '{\"Version\":2}'\n"
if err := os.WriteFile(binary, []byte(script), 0700); err != nil {
t.Fatal(err)
}
Expand Down
9 changes: 8 additions & 1 deletion services/core/internal/sandbox/microsandbox/identity.go
Original file line number Diff line number Diff line change
Expand Up @@ -71,8 +71,15 @@ func ValidateCompute(c Config, r sandbox.Reference, v Compute) error {
}
return nil
}

// SnapshotEvidence encodes the native digest, checkpoint ID and checkpoint root; the root is last because it may contain spaces.
func SnapshotEvidence(digest, checkpointID, root string) string {
return digest + " " + checkpointID + " " + root
}

func ValidateSnapshot(c Config, r sandbox.Reference, s SnapshotIdentity) error {
if s.Compatibility.Validate() != nil || !ValidReference(r) || !validID(s.OperationID) || s.Reference != SnapshotReference(c, r, s.OperationID) || s.SourceName != Name(c, r, s.SourceGeneration) || s.SourceID == "" || s.ID == "" || s.Digest == "" || s.CheckpointID == "" || s.CheckpointRoot == "" {
receipt := strings.SplitN(s.Evidence, " ", 3)
if s.Validate() != nil || !ValidReference(r) || !validID(s.OperationID) || s.Reference != SnapshotReference(c, r, s.OperationID) || s.SourceName != Name(c, r, s.SourceGeneration) || s.SourceID == "" || s.ID == "" || len(receipt) != 3 || receipt[0] == "" || receipt[1] == "" || receipt[2] == "" {
return sandbox.ErrInvalid
}
return nil
Expand Down
15 changes: 14 additions & 1 deletion services/core/internal/sandbox/microsandbox/provider_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ func testRef() sandbox.Reference {
func testSnapshot() SnapshotIdentity {
c, r := testConfig(), testRef()
op := "55555555-5555-4555-8555-555555555555"
return SnapshotIdentity{Compatibility: sandbox.CheckpointCompatibility{ArtifactDomain: "domain", ExecutionClass: "class"}, Reference: SnapshotReference(c, r, op), ID: "snap_exact", Digest: "digest", CheckpointID: "checkpoint", CheckpointRoot: "root", OperationID: op, SourceName: Name(c, r, 0), SourceID: "local:4"}
return SnapshotIdentity{Compatibility: sandbox.CheckpointCompatibility{ArtifactDomain: "domain", ExecutionClass: "class"}, Reference: SnapshotReference(c, r, op), ID: "snap_exact", Evidence: SnapshotEvidence("digest", "checkpoint", "root"), OperationID: op, SourceName: Name(c, r, 0), SourceID: "local:4"}
}
func deadline(t *testing.T) context.Context {
t.Helper()
Expand Down Expand Up @@ -167,3 +167,16 @@ func TestImageMustUseCompletePinnedOCIManifestDigest(t *testing.T) {
}
}
}

func TestSnapshotEvidenceIsRequired(t *testing.T) {
c, r, s := testConfig(), testRef(), testSnapshot()
if err := ValidateSnapshot(c, r, s); err != nil {
t.Fatal(err)
}
for _, receipt := range []string{"digest", "digest /root", "digest checkpoint "} {
s.Evidence = receipt
if ValidateSnapshot(c, r, s) == nil {
t.Fatal("malformed receipt accepted", receipt)
}
}
}
2 changes: 1 addition & 1 deletion services/core/internal/sandbox/microsandbox/types.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import (
"github.com/MiniMax-AI/OpenAgentCore/services/core/internal/sandbox"
)

const ProtocolVersion = 7
const ProtocolVersion = 8
const SDKVersion = "v0.7.8"
const MaxOutputBytes = 1024 * 1024
const MaxRequestBytes = 72 * 1024 * 1024
Expand Down
2 changes: 1 addition & 1 deletion services/core/internal/sandbox/node/wire.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ import (
"github.com/gorilla/websocket"
)

const ProtocolVersion = 8
const ProtocolVersion = 9
const MaxControlFrameBytes = 32 * 1024
const MaxFrameBytes = 72 * 1024 * 1024
const maxPending = 32
Expand Down
Loading
Loading