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
5 changes: 1 addition & 4 deletions apps/daemon/internal/agenthost/view_linux_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -363,10 +363,7 @@ func startSandbox(t *testing.T, bin string) *sandbox {
t.Fatalf("set %s to a static oac-sandbox-io", sandboxIOEnv)
}
auth := sandboxlinktest.NewAuthority()
rl, err := relay.New(relay.Config{Authority: auth})
if err != nil {
t.Fatal(err)
}
rl := relay.New(auth)
srv := httptest.NewServer(rl)
t.Cleanup(srv.Close)
t.Cleanup(func() { rl.Close() })
Expand Down
5 changes: 1 addition & 4 deletions apps/daemon/internal/agenthostqualify/qualify_linux_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -227,10 +227,7 @@ type sandbox struct {

func startSandbox(t *testing.T) *sandbox {
auth := sandboxlinktest.NewAuthority()
rl, err := relay.New(relay.Config{Authority: auth})
if err != nil {
t.Fatal(err)
}
rl := relay.New(auth)
srv := httptest.NewServer(rl)
t.Cleanup(srv.Close)
t.Cleanup(func() { rl.Close() })
Expand Down
5 changes: 2 additions & 3 deletions apps/daemon/internal/gateway/gateway_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,6 @@ import (
"github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto"
"github.com/MiniMax-AI/OpenAgentCore/internal/modelprovider"
"github.com/MiniMax-AI/OpenAgentCore/internal/sandboxlink"
"github.com/MiniMax-AI/OpenAgentCore/internal/sandboxlink/relay"
"github.com/MiniMax-AI/OpenAgentCore/internal/sandboxlink/sandboxlinktest"
"github.com/MiniMax-AI/OpenAgentCore/internal/sandboxnet"
"github.com/MiniMax-AI/OpenAgentCore/internal/sandboxwire"
Expand Down Expand Up @@ -105,7 +104,7 @@ func (s *sandbox) Dial(ctx context.Context, addr netip.AddrPort) (*net.TCPConn,
func startSandbox(t *testing.T) *sandbox {
t.Helper()
auth := sandboxlinktest.NewAuthority()
srv := sandboxlinktest.StartRelay(t, relay.Config{Authority: auth})
srv := sandboxlinktest.StartRelay(t, auth)
resource := sandboxlink.ResourceRef{TenantID: sandboxwire.NewID(), EnvironmentID: sandboxwire.NewID(),
Kind: sandboxlink.ResourceAllocation, ID: sandboxwire.NewID(), Generation: 1}
auth.AddServe([]byte("serve credential"), sandboxlink.ServePeer{PeerID: sandboxwire.NewID(), Resource: resource})
Expand All @@ -122,7 +121,7 @@ func startSandbox(t *testing.T) *sandbox {
Serve: func(ctx context.Context, b sandboxlink.Bind, _ uint64, s sandboxlink.Stream) {
sandboxnet.Serve(ctx, s, b.Egress, sb)
}}},
OnConnected: func(sandboxlink.HelloAccepted) {
OnConnected: func() {
select {
case connected <- struct{}{}:
default:
Expand Down
5 changes: 2 additions & 3 deletions apps/sandboxio/internal/netservice/service_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,6 @@ import (
"golang.org/x/net/dns/dnsmessage"

"github.com/MiniMax-AI/OpenAgentCore/internal/sandboxlink"
"github.com/MiniMax-AI/OpenAgentCore/internal/sandboxlink/relay"
"github.com/MiniMax-AI/OpenAgentCore/internal/sandboxlink/sandboxlinktest"
"github.com/MiniMax-AI/OpenAgentCore/internal/sandboxnet"
"github.com/MiniMax-AI/OpenAgentCore/internal/sandboxwire"
Expand Down Expand Up @@ -61,7 +60,7 @@ type fixture struct {

func newFixture(t *testing.T) *fixture {
auth := sandboxlinktest.NewAuthority()
f := &fixture{t: t, auth: auth, srv: sandboxlinktest.StartRelay(t, relay.Config{Authority: auth}), runtime: sandboxwire.NewID(),
f := &fixture{t: t, auth: auth, srv: sandboxlinktest.StartRelay(t, auth), runtime: sandboxwire.NewID(),
served: make(chan error, 16), blackhole: make(chan struct{}, 16)}
dns := f.startDNS()
svc := &Service{resolver: &net.Resolver{PreferGo: true, Dial: func(ctx context.Context, _, _ string) (net.Conn, error) {
Expand All @@ -81,7 +80,7 @@ func newFixture(t *testing.T) *fixture {
Serve: func(ctx context.Context, b sandboxlink.Bind, _ uint64, s sandboxlink.Stream) {
f.served <- sandboxnet.Serve(ctx, s, b.Egress, svc)
}}},
OnConnected: func(sandboxlink.HelloAccepted) {
OnConnected: func() {
select {
case connected <- struct{}{}:
default:
Expand Down
2 changes: 1 addition & 1 deletion apps/sandboxio/internal/sandboxio/sandboxio.go
Original file line number Diff line number Diff line change
Expand Up @@ -89,7 +89,7 @@ func run(ctx context.Context, bootstrapPath string, opt options) error {
}},
{Service: sandboxlink.ServiceNetwork, Version: sandboxnet.Version, Serve: netservice.New().Handle},
},
OnConnected: func(sandboxlink.HelloAccepted) { down.Store(false) },
OnConnected: func() { down.Store(false) },
OnDisconnected: func(err error) {
if !down.Swap(true) {
log.Printf("oac-sandbox-io: relay link ended, reconnecting: %v", err)
Expand Down
5 changes: 2 additions & 3 deletions apps/sandboxio/internal/sandboxio/sandboxio_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,6 @@ import (
"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"
"github.com/MiniMax-AI/OpenAgentCore/internal/sandboxnet"
sp "github.com/MiniMax-AI/OpenAgentCore/internal/sandboxprocess"
Expand Down Expand Up @@ -110,7 +109,7 @@ func next(t *testing.T, op *sp.Operation) sp.Event {
func TestServesEachProtocolThroughTheRelay(t *testing.T) {
ctx := context.Background()
auth := sandboxlinktest.NewAuthority()
srv := sandboxlinktest.StartRelay(t, relay.Config{Authority: auth})
srv := sandboxlinktest.StartRelay(t, auth)
bootstrap, resource := writeBootstrap(t, srv.URL, "serve-credential")
auth.AddServe([]byte("serve-credential"), sandboxlink.ServePeer{PeerID: sandboxwire.NewID(), Resource: resource})
runtimeID := sandboxwire.NewID()
Expand Down Expand Up @@ -254,7 +253,7 @@ func TestServesEachProtocolThroughTheRelay(t *testing.T) {
// 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()})
srv := sandboxlinktest.StartRelay(t, sandboxlinktest.NewAuthority())
bootstrap, _ := writeBootstrap(t, srv.URL, "unknown-credential")
ctx, cancel := context.WithTimeout(context.Background(), wait)
defer cancel()
Expand Down
15 changes: 5 additions & 10 deletions docs/sandbox-link-protocol.md
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ An attachment outlives its link. After reconnecting, the Runtime opens a stream

## Run a relay

`relay.New` takes a `relay.Config` with an `Authority` and returns a `*relay.Relay`, which is an `http.Handler`. The relay endpoint is served behind the installation's HTTPS ingress, which terminates TLS, so the handler accepts the upgrade on the ingress's plain HTTP hop; peers enforce TLS when they dial. `MaxStreams` (default 256) bounds each link's concurrent service streams and `MaxFrameBytes` (default 1 MiB) is the frame limit the relay advertises.
`relay.New` takes an `Authority` and returns a `*relay.Relay`, which is an `http.Handler`. The relay endpoint is served behind the installation's HTTPS ingress, which terminates TLS, so the handler accepts the upgrade on the ingress's plain HTTP hop; peers enforce TLS when they dial. Each link carries at most 256 concurrent service streams.

The owner of the relay implements `Authority` from its durable records, and the relay consults it for every Hello, Open and renewal. To revoke, withdraw the authority first, then call `RevokeAttachment` or `RevokeResource` so the relay closes what it holds.

Expand All @@ -55,7 +55,7 @@ A frame is a 16-byte header followed by the payload. Integers are big-endian.

| Offset | Field | Type | Rule |
| --- | --- | --- | --- |
| 0 | `PayloadLength` | uint32 | At most 1 MiB (`sandboxwire.MaxPayload`) and at most the limit advertised to the sender; checked before the payload is read |
| 0 | `PayloadLength` | uint32 | At most 1 MiB (`sandboxwire.MaxPayload`); checked before the payload is read |
| 4 | `MessageType` | uint16 | A tag the protocol defines |
| 6 | `Flags` | uint16 | Zero |
| 8 | `RequestID` | uint64 | For a request, nonzero and greater than the sender's previous request ID on the stream; the request's ID in its response; zero for an event |
Expand Down Expand Up @@ -124,10 +124,7 @@ Hello
RuntimeID ID
Credential bytes // 1..4096 bytes

HelloAccepted
LinkID ID
MaxStreams u32 // at least 1
MaxFrameBytes u32 // 16 KiB..1 MiB
HelloAccepted (no fields)

Open
Service enum
Expand All @@ -144,7 +141,6 @@ Opened
AttachmentID ID
ServerInstanceID ID
LeaseExpiresAt i64 ms
MaxFrameBytes u32

Bind
AttachmentID ID
Expand All @@ -155,7 +151,6 @@ Bind
AssignmentEpoch u64
LeaseExpiresAt i64 ms
ExpectedServerInstanceID ID // the serve peer's ServerInstanceID as the relay knows it
MaxFrameBytes u32
Exports optional, present exactly when Service is ServiceFile:
count 1..64 of ExportGrant, no ID twice
Egress optional, present exactly when Service is ServiceNetwork:
Expand Down Expand Up @@ -203,8 +198,6 @@ AttachmentClosed

Later control requests continue the Hello's request IDs. The relay ends an attach link whose request ID does not increase with `ProtocolViolation`. `Open` and `Bind` are each the only request on their stream and use request ID 1.

`HelloAccepted.MaxStreams` bounds the link's concurrent service streams. `MaxFrameBytes` bounds the payload of every frame on the link's service streams.

For a serve peer, the Authority returns the peer's identity and the resource, including generation, that the credential serves. The resource must equal the Hello's, otherwise the answer is `PermissionDenied`. The relay then applies the [generation rule](#authority-and-staleness) and makes the link the resource's current serve peer.

The relay and the serve peer bound each handshake step, the WebSocket upgrade, the Hello, reading an `Open`, a `Bind` and its answer and each Authority call, by `sandboxlink.HandshakeTimeout` (10 seconds). The attach peer bounds an Open with its context.
Expand Down Expand Up @@ -281,6 +274,8 @@ The relay copies each direction through a 32 KiB buffer and holds at most one 25

`ServiceUnavailable` and `LimitExceeded` are transient: the same request may succeed later, and `Code.Retryable` reports them. Every other code is final: repeating the request with the same credential, attachment and generation fails again.

An answer that is malformed, or that carries another request ID or operation than its request, fails the request with `ProtocolViolation` and `EffectPossible`, since the request may have taken effect.

## Verification

`go test ./internal/sandboxlink/...` covers the golden frames, decode rejection, and the relay's authorization, generation, lease, revocation, renewal bound and reconnect behavior, including orderly end and abort propagation. `go test -run '^$' -fuzz FuzzDecode ./internal/sandboxlink` fuzzes the decoder.
17 changes: 6 additions & 11 deletions docs/zh/sandbox-link-protocol.md
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
---
title: "沙箱 Link 协议"
source: docs/sandbox-link-protocol.md
source_hash: 0d6793b12de0dcb11c3355855c514538f78653fa90008f9865407d1792743859
source_hash: d753586c244650329796b8abd17994c2e57aa433b55ebd78c499c86368e8aa6f
---

Link 协议通过 relay 连接沙箱 I/O 的两端。Sandbox I/O 服务运行在沙箱内并为其提供服务,是 serve peer。agent host 上的 Runtime 在沙箱外运行 Harness,并通过该服务使用沙箱,是 attach peer。每个 peer 各自向 relay 认证自己的 link。relay 授权 attach peer 打开的每个服务 stream,将其绑定到该资源当前的 serve peer,然后在两个 stream 之间复制字节而不读取内容。服务帧从不携带凭据或 grant。
Expand Down Expand Up @@ -43,7 +43,7 @@ attachment 的生命周期长于其 link。重连后,Runtime 使用相同的 b

## 运行 relay {#run-a-relay}

`relay.New` 接收带 `Authority` 的 `relay.Config`,返回 `*relay.Relay`,它是一个 `http.Handler`。relay endpoint 位于安装实例的 HTTPS ingress 之后,由 ingress 终止 TLS,因此 handler 在 ingress 的明文 HTTP 一跳上接受 upgrade;peer 在拨号时强制 TLS。`MaxStreams`(默认 256)限制每条 link 的并发服务 stream,`MaxFrameBytes`(默认 1 MiB)是 relay 通告的帧上限。
`relay.New` 接收 `Authority`,返回 `*relay.Relay`,它是一个 `http.Handler`。relay endpoint 位于安装实例的 HTTPS ingress 之后,由 ingress 终止 TLS,因此 handler 在 ingress 的明文 HTTP 一跳上接受 upgrade;peer 在拨号时强制 TLS。每条 link 最多承载 256 个并发服务 stream。

relay 的 owner 基于其持久记录实现 `Authority`,relay 对每个 Hello、Open 和续期都咨询它。撤销时,先撤回授权,再调用 `RevokeAttachment` 或 `RevokeResource`,让 relay 关闭其持有的对象。

Expand All @@ -57,7 +57,7 @@ relay 的 owner 基于其持久记录实现 `Authority`,relay 对每个 Hello

| 偏移 | 字段 | 类型 | 规则 |
| --- | --- | --- | --- |
| 0 | `PayloadLength` | uint32 | 不超过 1 MiB(`sandboxwire.MaxPayload`),且不超过向发送方通告的上限;在读取 payload 前检查 |
| 0 | `PayloadLength` | uint32 | 不超过 1 MiB(`sandboxwire.MaxPayload`);在读取 payload 前检查 |
| 4 | `MessageType` | uint16 | 协议定义的 tag |
| 6 | `Flags` | uint16 | 零 |
| 8 | `RequestID` | uint64 | 请求中为非零值,且大于发送方在该 stream 上的上一个请求 ID;响应中为对应请求的 ID;事件中为零 |
Expand Down Expand Up @@ -126,10 +126,7 @@ Hello
RuntimeID ID
Credential bytes // 1..4096 bytes

HelloAccepted
LinkID ID
MaxStreams u32 // at least 1
MaxFrameBytes u32 // 16 KiB..1 MiB
HelloAccepted (no fields)

Open
Service enum
Expand All @@ -146,7 +143,6 @@ Opened
AttachmentID ID
ServerInstanceID ID
LeaseExpiresAt i64 ms
MaxFrameBytes u32

Bind
AttachmentID ID
Expand All @@ -157,7 +153,6 @@ Bind
AssignmentEpoch u64
LeaseExpiresAt i64 ms
ExpectedServerInstanceID ID // the serve peer's ServerInstanceID as the relay knows it
MaxFrameBytes u32
Exports optional, present exactly when Service is ServiceFile:
count 1..64 of ExportGrant, no ID twice
Egress optional, present exactly when Service is ServiceNetwork:
Expand Down Expand Up @@ -205,8 +200,6 @@ AttachmentClosed

后续控制请求延续 Hello 的请求 ID。attach link 的请求 ID 未递增时,relay 以 `ProtocolViolation` 结束该 link。`Open` 和 `Bind` 各自是其 stream 上唯一的请求,使用请求 ID 1。

`HelloAccepted.MaxStreams` 限制该 link 的并发服务 stream。`MaxFrameBytes` 限制该 link 服务 stream 上每个帧的 payload。

对于 serve peer,Authority 返回 peer 的身份及该凭据所服务的资源(包括 generation)。该资源必须与 Hello 中的一致,否则回复 `PermissionDenied`。随后 relay 应用 [generation 规则](#authority-and-staleness),并将该 link 设为资源当前的 serve peer。

relay 和 serve peer 以 `sandboxlink.HandshakeTimeout`(10 秒)限制每个握手步骤:WebSocket upgrade、Hello、读取 `Open`、`Bind` 及其回复,以及每次 Authority 调用。attach peer 用其 context 限制 Open。
Expand Down Expand Up @@ -283,6 +276,8 @@ relay 通过 32 KiB 缓冲区复制每个方向的数据,每个 stream 最多

`ServiceUnavailable` 和 `LimitExceeded` 是临时失败:相同请求稍后可能成功,`Code.Retryable` 将它们报告为可重试。其他 code 都是最终失败:以相同凭据、attachment 和 generation 重复请求会再次失败。

格式错误的响应,或其请求 ID、操作与请求不符的响应,会使该请求以 `ProtocolViolation` 和 `EffectPossible` 失败,因为请求可能已经生效。

## 验证 {#verification}

`go test ./internal/sandboxlink/...` 覆盖 golden 帧、解码拒绝,以及 relay 的授权、generation、lease、撤销、续期上限和重连行为,包括有序结束与中止的传播。`go test -run '^$' -fuzz FuzzDecode ./internal/sandboxlink` 对解码器进行 fuzz 测试。
Loading
Loading