Files
xinmi/internal/sandbox/e2b_remote_client_test.go
yawan f7fc992f57
Anydoc / anydoc parser engine (push) Canceled after 0s
App / Format, vet, test, and build (push) Canceled after 0s
CLI / test (macos-latest / go 1.26) (push) Canceled after 0s
CLI / test (ubuntu-latest / go 1.26) (push) Canceled after 0s
CLI / test (windows-latest / go 1.26) (push) Canceled after 0s
Build and Push Docker Image / build-ui (push) Canceled after 0s
Build and Push Docker Image / build-docreader (push) Canceled after 0s
Build and Push Docker Image / build-sandbox (push) Canceled after 0s
Build and Push Docker Image / build-app (amd64, linux/amd64, ubuntu-latest) (push) Canceled after 0s
Build and Push Docker Image / build-app (arm64, linux/arm64, ubuntu-24.04-arm) (push) Canceled after 0s
DocReader / Python tests and Go client tests (push) Canceled after 0s
Build & Publish (dsh-weknora) / Typecheck, test, pack (push) Canceled after 0s
Build & Publish (dsh-weknora) / End-to-end inside dsh (pinned) (push) Canceled after 0s
Build & Publish (dsh-weknora) / End-to-end inside dsh (latest, advisory) (push) Canceled after 0s
Frontend / Test, type-check, and build (push) Canceled after 0s
Go Lint Cache / warm lint cache (.) (push) Canceled after 0s
Go Lint Cache / warm lint cache (cli) (push) Canceled after 0s
Go Lint Cache / warm lint cache (client) (push) Canceled after 0s
Build & Publish (mcp-server) / Test (Python 3.10) (push) Canceled after 0s
Build & Publish (mcp-server) / Test (Python 3.11) (push) Canceled after 0s
Build & Publish (mcp-server) / Test (Python 3.12) (push) Canceled after 0s
Build & Publish (mcp-server) / Test (Python 3.13) (push) Canceled after 0s
Build and Push Docker Image / merge (push) Canceled after 0s
Build & Publish (dsh-weknora) / Publish to npm (push) Canceled after 0s
Build & Publish (mcp-server) / Build sdist + wheel (push) Canceled after 0s
Build & Publish (mcp-server) / Publish to PyPI (push) Canceled after 0s
WeKnora deployment source with the reviewed wiki identity, summary and recovery fixes
2026-09-22 16:19:06 +08:00

1518 lines
51 KiB
Go

package sandbox
import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net"
"net/http"
"net/http/httptest"
"net/url"
"sort"
"strconv"
"strings"
"sync"
"sync/atomic"
"testing"
"time"
"connectrpc.com/connect"
"github.com/Tencent/WeKnora/internal/types"
e2b "github.com/matiasinsaurralde/go-e2b"
"github.com/stretchr/testify/require"
)
type e2bTestNetError struct {
timeout bool
}
func (e e2bTestNetError) Error() string { return "network failure" }
func (e e2bTestNetError) Timeout() bool { return e.timeout }
func (e e2bTestNetError) Temporary() bool { return false }
var _ net.Error = e2bTestNetError{}
// e2bMockServer emulates the E2B control-plane endpoints used by
// E2BRemoteClient. Per-sandbox envd traffic is outside this mock.
type e2bMockServer struct {
server *httptest.Server
createCount atomic.Int32
listCount atomic.Int32
v2ListCount atomic.Int32
connectCount atomic.Int32
infoCount atomic.Int32
deleteCount atomic.Int32
nextID atomic.Int64
createBody map[string]any
connectBody map[string]any
connectID string
deleteID string
mu sync.Mutex
v2Queries []url.Values
snapshotQueries []url.Values
repeatV2NextToken bool
unsafeV2NextToken string
repeatSnapshotNextToken bool
snapshotPageSize int
snapshotDeleteFailWith int
snapshotCreateBody map[string]any
sandboxes map[string]map[string]any // sandboxID -> SandboxInfo JSON
snapshots map[string]map[string]any // snapshotID -> SnapshotInfo JSON
trafficAccessToken string
}
func newE2BMockServer(t *testing.T) *e2bMockServer {
t.Helper()
m := &e2bMockServer{
sandboxes: map[string]map[string]any{},
snapshots: map[string]map[string]any{},
}
m.server = httptest.NewServer(http.HandlerFunc(m.handle))
t.Cleanup(m.server.Close)
return m
}
func (m *e2bMockServer) URL() string { return m.server.URL }
func (m *e2bMockServer) handle(w http.ResponseWriter, r *http.Request) {
switch {
case r.URL.Path == "/sandboxes" && r.Method == http.MethodPost:
m.createCount.Add(1)
body, _ := io.ReadAll(r.Body)
_ = json.Unmarshal(body, &m.createBody)
id := "e2b-" + strconv.FormatInt(m.nextID.Add(1), 10)
m.sandboxes[id] = map[string]any{
"sandboxID": id,
"templateID": m.createBody["templateID"],
"state": "running",
"envdVersion": "test",
"startedAt": time.Now().UTC().Format(time.RFC3339),
"metadata": m.createBody["metadata"],
}
m.mu.Lock()
trafficAccessToken := m.trafficAccessToken
m.mu.Unlock()
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusCreated)
response := map[string]any{
"sandboxID": id,
"envdAccessToken": "token-" + id,
}
if trafficAccessToken != "" {
response["trafficAccessToken"] = trafficAccessToken
}
_ = json.NewEncoder(w).Encode(response)
case r.URL.Path == "/sandboxes" && r.Method == http.MethodGet:
m.listCount.Add(1)
items := make([]map[string]any, 0, len(m.sandboxes))
for _, info := range m.sandboxes {
if info["state"] == "running" {
items = append(items, info)
}
}
writeJSON(w, http.StatusOK, items)
case r.URL.Path == "/v2/sandboxes" && r.Method == http.MethodGet:
m.handleListV2(w, r)
case strings.HasPrefix(r.URL.Path, "/sandboxes/") &&
strings.HasSuffix(r.URL.Path, "/snapshots") &&
r.Method == http.MethodPost:
id := strings.TrimSuffix(strings.TrimPrefix(r.URL.Path, "/sandboxes/"), "/snapshots")
if _, ok := m.sandboxes[id]; !ok {
http.NotFound(w, r)
return
}
body, _ := io.ReadAll(r.Body)
_ = json.Unmarshal(body, &m.snapshotCreateBody)
snapshotID := "snap-" + strconv.Itoa(len(m.snapshots)+1)
name, _ := m.snapshotCreateBody["name"].(string)
names := []string{}
if name != "" {
names = append(names, name)
}
m.snapshots[snapshotID] = map[string]any{
"snapshotID": snapshotID,
"sandboxID": id,
"names": names,
}
writeJSON(w, http.StatusCreated, m.snapshots[snapshotID])
case r.URL.Path == "/snapshots" && r.Method == http.MethodGet:
m.handleListSnapshots(w, r)
case strings.HasPrefix(r.URL.Path, "/sandboxes/") &&
strings.HasSuffix(r.URL.Path, "/connect") &&
r.Method == http.MethodPost:
id := strings.TrimSuffix(strings.TrimPrefix(r.URL.Path, "/sandboxes/"), "/connect")
info, ok := m.sandboxes[id]
if !ok {
http.NotFound(w, r)
return
}
m.connectCount.Add(1)
m.connectID = id
_ = json.NewDecoder(r.Body).Decode(&m.connectBody)
info["state"] = "running"
writeJSON(w, http.StatusOK, map[string]any{
"sandboxID": id,
"envdAccessToken": "reconnected-token-" + id,
})
case strings.HasPrefix(r.URL.Path, "/sandboxes/") &&
!strings.HasSuffix(r.URL.Path, "/connect") &&
r.Method == http.MethodGet:
// Per-sandbox info endpoint (GET /sandboxes/{id}). Read-only: it
// reports the stored state without mutating it.
id := strings.TrimPrefix(r.URL.Path, "/sandboxes/")
info, ok := m.sandboxes[id]
if !ok {
http.NotFound(w, r)
return
}
m.infoCount.Add(1)
writeJSON(w, http.StatusOK, info)
case strings.HasPrefix(r.URL.Path, "/sandboxes/") && r.Method == http.MethodDelete:
id := strings.TrimPrefix(r.URL.Path, "/sandboxes/")
if _, ok := m.sandboxes[id]; !ok {
http.NotFound(w, r)
return
}
m.deleteCount.Add(1)
m.deleteID = id
delete(m.sandboxes, id)
w.WriteHeader(http.StatusNoContent)
case strings.HasPrefix(r.URL.Path, "/templates/") && r.Method == http.MethodDelete:
if m.snapshotDeleteFailWith != 0 {
http.Error(w, "delete failed", m.snapshotDeleteFailWith)
return
}
id := strings.TrimPrefix(r.URL.Path, "/templates/")
if _, ok := m.snapshots[id]; !ok {
http.NotFound(w, r)
return
}
delete(m.snapshots, id)
w.WriteHeader(http.StatusNoContent)
default:
http.NotFound(w, r)
}
}
func (m *e2bMockServer) handleListV2(w http.ResponseWriter, r *http.Request) {
m.v2ListCount.Add(1)
m.mu.Lock()
m.v2Queries = append(m.v2Queries, r.URL.Query())
repeatToken := m.repeatV2NextToken
unsafeToken := m.unsafeV2NextToken
m.mu.Unlock()
if len(r.URL.Query()["metadata"]) > 1 {
writeJSON(w, http.StatusBadRequest, map[string]any{
"code": http.StatusBadRequest,
"message": "metadata accepts a single value",
})
return
}
states := make(map[string]bool)
for _, state := range r.URL.Query()["state"] {
states[state] = true
}
metadata := make(map[string]string)
for _, item := range r.URL.Query()["metadata"] {
key, value, ok := strings.Cut(item, "=")
if ok {
metadata[key] = value
}
}
ids := make([]string, 0, len(m.sandboxes))
for id, info := range m.sandboxes {
state, _ := info["state"].(string)
if len(states) > 0 && !states[state] {
continue
}
if len(states) == 0 && state != "running" && state != "paused" {
continue
}
if !mockMetadataMatches(info["metadata"], metadata) {
continue
}
ids = append(ids, id)
}
sort.Strings(ids)
limit := 100
if raw := r.URL.Query().Get("limit"); raw != "" {
if parsed, err := strconv.Atoi(raw); err == nil && parsed > 0 {
limit = parsed
}
}
start := 0
if raw := r.URL.Query().Get("nextToken"); raw != "" && raw != "repeat" {
start, _ = strconv.Atoi(raw)
}
if start > len(ids) {
start = len(ids)
}
end := start + limit
if end > len(ids) {
end = len(ids)
}
items := make([]map[string]any, 0, end-start)
for _, id := range ids[start:end] {
items = append(items, m.sandboxes[id])
}
if unsafeToken != "" {
w.Header().Set("X-Next-Token", unsafeToken)
} else if repeatToken {
w.Header().Set("X-Next-Token", "repeat")
} else if end < len(ids) {
w.Header().Set("X-Next-Token", strconv.Itoa(end))
}
writeJSON(w, http.StatusOK, items)
}
func (m *e2bMockServer) handleListSnapshots(w http.ResponseWriter, r *http.Request) {
m.mu.Lock()
m.snapshotQueries = append(m.snapshotQueries, r.URL.Query())
repeatToken := m.repeatSnapshotNextToken
pageSize := m.snapshotPageSize
m.mu.Unlock()
sandboxID := r.URL.Query().Get("sandboxID")
ids := make([]string, 0, len(m.snapshots))
for id, info := range m.snapshots {
if sandboxID != "" && info["sandboxID"] != sandboxID {
continue
}
ids = append(ids, id)
}
sort.Strings(ids)
limit := 100
if pageSize > 0 {
limit = pageSize
}
if raw := r.URL.Query().Get("limit"); raw != "" {
if parsed, err := strconv.Atoi(raw); err == nil && parsed > 0 && pageSize == 0 {
limit = parsed
}
}
start := 0
if raw := r.URL.Query().Get("nextToken"); raw != "" && raw != "repeat" {
start, _ = strconv.Atoi(raw)
}
if start > len(ids) {
start = len(ids)
}
end := start + limit
if end > len(ids) {
end = len(ids)
}
items := make([]map[string]any, 0, end-start)
for _, id := range ids[start:end] {
items = append(items, m.snapshots[id])
}
if repeatToken {
w.Header().Set("X-Next-Token", "repeat")
} else if end < len(ids) {
w.Header().Set("X-Next-Token", strconv.Itoa(end))
}
writeJSON(w, http.StatusOK, items)
}
func mockMetadataMatches(raw any, expected map[string]string) bool {
if len(expected) == 0 {
return true
}
actual, ok := raw.(map[string]any)
if !ok {
return false
}
for key, value := range expected {
if actual[key] != value {
return false
}
}
return true
}
func newTestE2BRemoteClient(t *testing.T, mock *e2bMockServer) *E2BRemoteClient {
t.Helper()
cfg := &Config{
E2BAPIKey: "key-test",
E2BAPIURL: mock.URL(),
E2BTemplate: "template-a",
E2BSandboxTTL: 10 * time.Minute,
}
client, err := NewE2BRemoteClient(cfg)
require.NoError(t, err)
return client
}
func TestE2BRemoteClientProviderAndCapabilities(t *testing.T) {
client := newTestE2BRemoteClient(t, newE2BMockServer(t))
require.Equal(t, SandboxTypeE2B, client.Provider())
require.Equal(t, RemoteSandboxCapabilities{
SupportsReconnect: true,
SupportsMetadata: true,
SupportsListSandboxes: true,
SupportsPauseResume: true,
SupportsTimeoutRefresh: true,
SupportsFilesystemEnumeration: true,
SupportsSnapshots: true,
SupportsVolumes: false,
}, client.Capabilities())
}
func TestE2BRemoteClientCreateSnapshot(t *testing.T) {
mock := newE2BMockServer(t)
client := newTestE2BRemoteClient(t, mock)
ctx := context.Background()
handle, err := client.Create(ctx, RemoteCreateRequest{TemplateID: "template-a"})
require.NoError(t, err)
ref, err := client.CreateSnapshot(ctx, handle.ID(), "weknora-sk-cfg1-g1")
require.NoError(t, err)
require.Equal(t, "snap-1", ref.ID)
require.Equal(t, []string{"weknora-sk-cfg1-g1"}, ref.Names)
require.Equal(t, "weknora-sk-cfg1-g1", mock.snapshotCreateBody["name"])
require.Equal(t, int32(1), mock.connectCount.Load())
}
func TestE2BRemoteClientCreateSnapshotRejectsEmptySandboxID(t *testing.T) {
client := newTestE2BRemoteClient(t, newE2BMockServer(t))
_, err := client.CreateSnapshot(context.Background(), " ", "n")
require.Error(t, err)
require.True(t, IsRemoteInvalidRequest(err))
}
func TestE2BRemoteClientDeleteSnapshotTreatsMissingAsSuccess(t *testing.T) {
client := newTestE2BRemoteClient(t, newE2BMockServer(t))
err := client.DeleteSnapshot(context.Background(), "snap-missing")
require.NoError(t, err, "a missing snapshot must not fail the delete path")
}
func TestE2BRemoteClientDeleteSnapshotRejectsEmptySnapshotID(t *testing.T) {
client := newTestE2BRemoteClient(t, newE2BMockServer(t))
err := client.DeleteSnapshot(context.Background(), " ")
require.Error(t, err)
require.True(t, IsRemoteInvalidRequest(err))
}
func TestE2BRemoteClientDeleteSnapshotReturnsUnexpectedErrors(t *testing.T) {
mock := newE2BMockServer(t)
mock.snapshotDeleteFailWith = http.StatusInternalServerError
client := newTestE2BRemoteClient(t, mock)
err := client.DeleteSnapshot(context.Background(), "snap-any")
require.Error(t, err)
require.False(t, IsRemoteNotFound(err))
}
func TestE2BRemoteClientListSnapshotsRejectsStuckPagination(t *testing.T) {
mock := newE2BMockServer(t)
mock.repeatSnapshotNextToken = true
client := newTestE2BRemoteClient(t, mock)
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()
_, err := client.ListSnapshots(ctx, "")
require.Error(t, err)
require.True(t, IsRemoteInvalidRequest(err))
}
func TestE2BRemoteClientListSnapshotsPagesAllResults(t *testing.T) {
mock := newE2BMockServer(t)
mock.snapshotPageSize = 1
client := newTestE2BRemoteClient(t, mock)
ctx := context.Background()
first, err := client.Create(ctx, RemoteCreateRequest{TemplateID: "template-a"})
require.NoError(t, err)
second, err := client.Create(ctx, RemoteCreateRequest{TemplateID: "template-a"})
require.NoError(t, err)
firstRef, err := client.CreateSnapshot(ctx, first.ID(), "weknora-sk-cfg1-g1")
require.NoError(t, err)
secondRef, err := client.CreateSnapshot(ctx, first.ID(), "weknora-sk-cfg2-g1")
require.NoError(t, err)
_, err = client.CreateSnapshot(ctx, second.ID(), "other")
require.NoError(t, err)
list, err := client.ListSnapshots(ctx, first.ID())
require.NoError(t, err)
require.Equal(t, []RemoteSnapshotRef{firstRef, secondRef}, list)
require.Len(t, mock.snapshotQueries, 2)
require.Equal(t, first.ID(), mock.snapshotQueries[0].Get("sandboxID"))
require.Equal(t, "100", mock.snapshotQueries[0].Get("limit"))
require.Equal(t, "1", mock.snapshotQueries[1].Get("nextToken"))
}
func TestE2BRemoteClientListTemplatesReconcilesStandardTemplateBuildStatus(t *testing.T) {
var statusRequests atomic.Int32
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/templates":
writeJSON(w, http.StatusOK, []map[string]any{{
"templateID": "template-ready",
"buildID": "build-success",
"names": []string{"project/weknora"},
"aliases": []string{"weknora"},
"buildStatus": "waiting",
"envdVersion": "0.4.0",
}})
case "/templates/template-ready/builds/build-success/status":
statusRequests.Add(1)
writeJSON(w, http.StatusOK, map[string]any{
"templateID": "template-ready",
"buildID": "build-success",
"status": "ready",
})
default:
http.NotFound(w, r)
}
}))
t.Cleanup(server.Close)
client, err := NewE2BRemoteClient(&Config{
E2BAPIKey: "key-test",
E2BAPIURL: server.URL,
E2BTemplate: "template-ready",
E2BSandboxTTL: time.Minute,
})
require.NoError(t, err)
templates, err := client.ListTemplates(context.Background())
require.NoError(t, err)
require.Len(t, templates, 1)
require.True(t, templates[0].Standard)
require.Equal(t, "project/weknora", templates[0].Name)
require.Equal(t, "ready", templates[0].Status)
require.Equal(t, int32(1), statusRequests.Load())
}
// An older successful build does not make the current one spawnable: E2B boots
// the current build only, and reporting "ready" here let the UI hand out a
// template that every sandbox creation rejected with a 404.
func TestE2BRemoteClientListTemplatesIgnoresOlderSuccessfulBuild(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/templates":
writeJSON(w, http.StatusOK, []map[string]any{{
"templateID": "template-queued",
"buildID": "build-queued",
"names": []string{"project/weknora"},
"buildStatus": "waiting",
}})
case "/templates/template-queued/builds/build-queued/status":
writeJSON(w, http.StatusOK, map[string]any{
"templateID": "template-queued",
"buildID": "build-queued",
"status": "waiting",
})
case "/templates/template-queued":
writeJSON(w, http.StatusOK, map[string]any{
"templateID": "template-queued",
"names": []string{"project/weknora"},
"builds": []map[string]any{
{"buildID": "build-queued", "status": "waiting"},
{"buildID": "build-older", "status": "success"},
},
})
default:
http.NotFound(w, r)
}
}))
t.Cleanup(server.Close)
client, err := NewE2BRemoteClient(&Config{
E2BAPIKey: "key-test",
E2BAPIURL: server.URL,
E2BSandboxTTL: time.Minute,
})
require.NoError(t, err)
templates, err := client.ListTemplates(context.Background())
require.NoError(t, err)
require.Len(t, templates, 1)
require.Equal(t, "waiting", templates[0].Status)
}
// E2B reports the all-zero UUID for a template that never got a build attached.
// Asking the build endpoint about it only yields a 400, and the template cannot
// be spawned, so the pending list status stands.
func TestE2BRemoteClientListTemplatesKeepsPendingWithoutBuildReference(t *testing.T) {
var buildRequests atomic.Int32
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch {
case r.URL.Path == "/templates":
writeJSON(w, http.StatusOK, []map[string]any{{
"templateID": "template-unbuilt",
"buildID": "00000000-0000-0000-0000-000000000000",
"names": []string{"project/weknora"},
"buildStatus": "building",
"spawnCount": 0,
}})
case strings.Contains(r.URL.Path, "/builds/"):
buildRequests.Add(1)
http.Error(w, `{"code":400,"message":"Build not found"}`, http.StatusBadRequest)
default:
http.NotFound(w, r)
}
}))
t.Cleanup(server.Close)
client, err := NewE2BRemoteClient(&Config{
E2BAPIKey: "key-test",
E2BAPIURL: server.URL,
E2BSandboxTTL: time.Minute,
})
require.NoError(t, err)
templates, err := client.ListTemplates(context.Background())
require.NoError(t, err)
require.Len(t, templates, 1)
require.Equal(t, "building", templates[0].Status)
require.Equal(t, int32(0), buildRequests.Load())
}
func TestE2BRemoteClientListTemplatesKeepsPendingWithoutSuccessfulBuild(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/templates":
writeJSON(w, http.StatusOK, []map[string]any{{
"templateID": "template-fresh",
"buildID": "build-fresh",
"names": []string{"project/weknora"},
"buildStatus": "building",
}})
case "/templates/template-fresh/builds/build-fresh/status":
writeJSON(w, http.StatusOK, map[string]any{
"templateID": "template-fresh",
"buildID": "build-fresh",
"status": "building",
})
case "/templates/template-fresh":
writeJSON(w, http.StatusOK, map[string]any{
"templateID": "template-fresh",
"builds": []map[string]any{
{"buildID": "build-fresh", "status": "building"},
},
})
default:
http.NotFound(w, r)
}
}))
t.Cleanup(server.Close)
client, err := NewE2BRemoteClient(&Config{
E2BAPIKey: "key-test",
E2BAPIURL: server.URL,
E2BSandboxTTL: time.Minute,
})
require.NoError(t, err)
templates, err := client.ListTemplates(context.Background())
require.NoError(t, err)
require.Len(t, templates, 1)
require.Equal(t, "building", templates[0].Status)
}
// Builds that finished but carry no default tag look exactly like "still
// building" in the template list, while sandbox creation rejects the template
// outright. The catalog has to name that state so the UI stops waiting.
func TestE2BRemoteClientListTemplatesReportsUntaggedBuilds(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/templates":
writeJSON(w, http.StatusOK, []map[string]any{{
"templateID": "template-untagged",
"buildID": "00000000-0000-0000-0000-000000000000",
"names": []string{"project/weknora"},
"buildStatus": "waiting",
"buildCount": 2,
}})
case "/templates/template-untagged":
writeJSON(w, http.StatusOK, map[string]any{
"templateID": "template-untagged",
"builds": []map[string]any{
{"buildID": "build-1", "status": "success"},
{"buildID": "build-2", "status": "success"},
},
})
default:
http.NotFound(w, r)
}
}))
t.Cleanup(server.Close)
client, err := NewE2BRemoteClient(&Config{
E2BAPIKey: "key-test",
E2BAPIURL: server.URL,
E2BSandboxTTL: time.Minute,
})
require.NoError(t, err)
templates, err := client.ListTemplates(context.Background())
require.NoError(t, err)
require.Len(t, templates, 1)
require.Equal(t, TemplateStatusUntagged, templates[0].Status)
}
// A template whose first build is genuinely still running has no finished build
// to find, so it must keep reporting the pending status.
func TestE2BRemoteClientListTemplatesKeepsPendingOnFirstBuild(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/templates":
writeJSON(w, http.StatusOK, []map[string]any{{
"templateID": "template-first",
"buildID": "00000000-0000-0000-0000-000000000000",
"names": []string{"project/weknora"},
"buildStatus": "building",
"buildCount": 1,
}})
case "/templates/template-first":
writeJSON(w, http.StatusOK, map[string]any{
"templateID": "template-first",
"builds": []map[string]any{{"buildID": "build-1", "status": "building"}},
})
default:
http.NotFound(w, r)
}
}))
t.Cleanup(server.Close)
client, err := NewE2BRemoteClient(&Config{
E2BAPIKey: "key-test",
E2BAPIURL: server.URL,
E2BSandboxTTL: time.Minute,
})
require.NoError(t, err)
templates, err := client.ListTemplates(context.Background())
require.NoError(t, err)
require.Len(t, templates, 1)
require.Equal(t, "building", templates[0].Status)
}
// Past spawns say nothing about the build that is current now, so they must not
// upgrade a pending status either.
func TestE2BRemoteClientListTemplatesIgnoresSpawnCount(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/templates":
writeJSON(w, http.StatusOK, []map[string]any{{
"templateID": "template-spawned",
"buildID": "build-x",
"names": []string{"weknora"},
"buildStatus": "waiting",
"spawnCount": 4,
}})
case "/templates/template-spawned/builds/build-x/status":
writeJSON(w, http.StatusOK, map[string]any{
"templateID": "template-spawned",
"buildID": "build-x",
"status": "waiting",
})
default:
http.NotFound(w, r)
}
}))
t.Cleanup(server.Close)
client, err := NewE2BRemoteClient(&Config{
E2BAPIKey: "key-test",
E2BAPIURL: server.URL,
E2BSandboxTTL: time.Minute,
})
require.NoError(t, err)
templates, err := client.ListTemplates(context.Background())
require.NoError(t, err)
require.Len(t, templates, 1)
require.Equal(t, "waiting", templates[0].Status)
}
func TestNormalizeE2BTemplateBuildStatus(t *testing.T) {
tests := map[string]string{
"READY": "ready",
"success": "ready",
"completed": "ready",
"processing": "building",
"uploaded": "waiting",
"failed": "error",
"custom": "custom",
}
for raw, want := range tests {
require.Equalf(t, want, normalizeE2BTemplateBuildStatus(raw), "status %q", raw)
}
}
func TestE2BRemoteClientCreateWritesMetadataAndPauseLifecycle(t *testing.T) {
mock := newE2BMockServer(t)
client := newTestE2BRemoteClient(t, mock)
metadata := map[string]string{"owner": "session-1"}
handle, err := client.Create(context.Background(), RemoteCreateRequest{
TemplateID: "template-a",
Timeout: RemoteTimeoutPolicy{
Mode: RemoteTimeoutExplicit,
Value: 15 * time.Minute,
Action: RemoteOnTimeoutPause,
AutoResume: true,
},
Metadata: metadata,
EnvVars: map[string]string{"LANG": "C.UTF-8"},
})
require.NoError(t, err)
require.NotEmpty(t, handle.ID())
require.Equal(t, SandboxTypeE2B, handle.Provider())
require.Equal(t, metadata, handle.Metadata())
metadata["owner"] = "mutated-request"
handle.Metadata()["owner"] = "mutated-return"
require.Equal(t, "session-1", handle.Metadata()["owner"])
require.Equal(t, "template-a", mock.createBody["templateID"])
require.Equal(t, float64(900), mock.createBody["timeout"])
require.Equal(t, map[string]any{"LANG": "C.UTF-8"}, mock.createBody["envVars"])
require.Equal(t, map[string]any{"owner": "session-1"}, mock.createBody["metadata"])
require.Equal(t, true, mock.createBody["autoPause"])
require.Equal(t, true, mock.createBody["autoPauseMemory"])
require.Equal(t, map[string]any{"enabled": true}, mock.createBody["autoResume"])
// Network defaults: egress on so `pip install` keeps working, inbound
// CLOSED so the sandbox URL is not reachable by anyone holding the ID.
// The inbound half is intentionally the opposite of E2B's own default.
require.Equal(t, true, mock.createBody["secure"])
require.Equal(t, true, mock.createBody["allow_internet_access"])
networkPayload, ok := mock.createBody["network"].(map[string]any)
require.True(t, ok, "network payload missing: %#v", mock.createBody["network"])
require.Equal(t, false, networkPayload["allowPublicTraffic"])
}
func TestE2BRemoteClientCreateValidatesTimeoutAction(t *testing.T) {
tests := []struct {
name string
action RemoteTimeoutAction
autoResume bool
wantError bool
}{
{name: "default is kill"},
{name: "explicit kill", action: RemoteOnTimeoutKill},
{name: "invalid action", action: RemoteTimeoutAction("hibernate"), wantError: true},
{name: "kill with auto resume", action: RemoteOnTimeoutKill, autoResume: true, wantError: true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
mock := newE2BMockServer(t)
client := newTestE2BRemoteClient(t, mock)
_, err := client.Create(context.Background(), RemoteCreateRequest{
TemplateID: "template-a",
Timeout: RemoteTimeoutPolicy{
Action: tt.action,
AutoResume: tt.autoResume,
},
})
if tt.wantError {
require.True(t, IsRemoteInvalidRequest(err))
require.Zero(t, mock.createCount.Load())
return
}
require.NoError(t, err)
require.NotContains(t, mock.createBody, "autoPause")
require.NotContains(t, mock.createBody, "autoResume")
})
}
}
func TestE2BRemoteClientCreateForwardsNetworkPolicy(t *testing.T) {
mock := newE2BMockServer(t)
client := newTestE2BRemoteClient(t, mock)
deny := false
privateSandbox := false
_, err := client.Create(context.Background(), RemoteCreateRequest{
TemplateID: "template-a",
Network: RemoteNetworkPolicy{
AllowInternetAccess: &deny,
AllowPublicTraffic: &privateSandbox,
AllowOut: []string{"*.example.com"},
DenyOut: []string{"0.0.0.0/0"},
E2BHostRules: []RemoteE2BHostRule{{
Host: "api.example.com",
Headers: map[string]string{"X-Environment": "production"},
}},
},
})
require.NoError(t, err)
require.Equal(t, false, mock.createBody["allow_internet_access"])
networkPayload, ok := mock.createBody["network"].(map[string]any)
require.True(t, ok)
require.Equal(t, false, networkPayload["allowPublicTraffic"])
require.Equal(t, []any{"*.example.com"}, networkPayload["allowOut"])
require.Equal(t, []any{"0.0.0.0/0"}, networkPayload["denyOut"])
rules, ok := networkPayload["rules"].(map[string]any)
require.True(t, ok, "rules payload missing: %#v", networkPayload["rules"])
hostRules := rules["api.example.com"].([]any)
require.Len(t, hostRules, 1)
transform := hostRules[0].(map[string]any)["transform"].(map[string]any)
headers := transform["headers"].(map[string]any)
require.Equal(t, "production", headers["X-Environment"])
}
// Regression: a stored "deny by default + domain allowlist" policy used to
// reach E2B as allow_internet_access=false with an empty denyOut, and the
// provider answered
//
// 400 When specifying allowed domains in allow out, you must include
// 'ALL_TRAFFIC' in deny out to block all other traffic.
//
// so every session on that config failed at create time. The existing wire
// test could not catch it because it hand-built the resolved policy with the
// sentinel already in place; this one starts from the stored shape and goes
// through the real resolver.
func TestE2BRemoteClientCreateSendsDenyAllForStoredDenyByDefault(t *testing.T) {
tenantCfg := &types.TenantSandboxConfig{
SandboxType: "e2b",
E2B: &types.E2BSandboxConfig{
APIKey: "key-test",
TemplateID: "template-a",
},
Network: &types.SandboxNetworkPolicy{
DenyEgressByDefault: true,
AllowOut: []string{"*.example.com"},
},
}
effective, err := ResolveEffectiveConfig(tenantCfg, DefaultConfig())
require.NoError(t, err)
mock := newE2BMockServer(t)
client := newTestE2BRemoteClient(t, mock)
_, err = client.Create(context.Background(), RemoteCreateRequest{
TemplateID: "template-a",
Network: effective.Network,
})
require.NoError(t, err)
require.Equal(t, false, mock.createBody["allow_internet_access"])
networkPayload := mock.createBody["network"].(map[string]any)
require.Equal(t, []any{"*.example.com"}, networkPayload["allowOut"])
require.Equal(t, []any{"0.0.0.0/0"}, networkPayload["denyOut"],
"E2B refuses a domain allowlist without an explicit deny-all")
}
func TestE2BRemoteClientCreateOmitsRulesWhenNoneConfigured(t *testing.T) {
mock := newE2BMockServer(t)
client := newTestE2BRemoteClient(t, mock)
_, err := client.Create(context.Background(), RemoteCreateRequest{TemplateID: "template-a"})
require.NoError(t, err)
networkPayload := mock.createBody["network"].(map[string]any)
require.NotContains(t, networkPayload, "rules")
}
// Same deliberate inversion as the Cube adapter: unspecified means inbound
// closed. E2B additionally requires Secure=true for this, which the adapter
// already pins.
func TestE2BRemoteClientCreateDefaultsInboundClosed(t *testing.T) {
mock := newE2BMockServer(t)
client := newTestE2BRemoteClient(t, mock)
_, err := client.Create(context.Background(), RemoteCreateRequest{TemplateID: "template-a"})
require.NoError(t, err)
require.Equal(t, true, mock.createBody["secure"])
require.Equal(t, true, mock.createBody["allow_internet_access"])
networkPayload := mock.createBody["network"].(map[string]any)
require.Equal(t, false, networkPayload["allowPublicTraffic"])
}
func TestE2BRemoteHandleExposesTrafficAccessToken(t *testing.T) {
mock := newE2BMockServer(t)
mock.mu.Lock()
mock.trafficAccessToken = "traffic-token"
mock.mu.Unlock()
client := newTestE2BRemoteClient(t, mock)
handle, err := client.Create(context.Background(), RemoteCreateRequest{TemplateID: "template-a"})
require.NoError(t, err)
carrier, ok := handle.(RemoteInboundTokenCarrier)
require.True(t, ok, "e2b handle must expose the inbound token for the binding")
require.Equal(t, "traffic-token", carrier.TrafficAccessToken())
}
func TestE2BRemoteClientCreatePreservesTimeoutModes(t *testing.T) {
t.Run("server default falls back to config TTL", func(t *testing.T) {
mock := newE2BMockServer(t)
client := newTestE2BRemoteClient(t, mock)
_, err := client.Create(context.Background(), RemoteCreateRequest{
TemplateID: "template-a",
Timeout: RemoteTimeoutPolicy{
Mode: RemoteTimeoutServerDefault,
Action: RemoteOnTimeoutKill,
},
})
require.NoError(t, err)
require.Equal(t, float64(600), mock.createBody["timeout"])
})
t.Run("never rejected", func(t *testing.T) {
client := newTestE2BRemoteClient(t, newE2BMockServer(t))
_, err := client.Create(context.Background(), RemoteCreateRequest{
TemplateID: "template-a",
Timeout: RemoteTimeoutPolicy{
Mode: RemoteTimeoutExplicit,
Value: -time.Hour,
Action: RemoteOnTimeoutKill,
},
})
require.True(t, IsRemoteInvalidRequest(err))
})
t.Run("missing template rejected before wire", func(t *testing.T) {
cfg := &Config{E2BAPIKey: "key-test", E2BAPIURL: newE2BMockServer(t).URL()}
client, err := NewE2BRemoteClient(cfg)
require.NoError(t, err)
_, err = client.Create(context.Background(), RemoteCreateRequest{})
require.True(t, IsRemoteInvalidRequest(err))
})
}
func TestE2BTimeoutSeconds(t *testing.T) {
tests := []struct {
name string
policy RemoteTimeoutPolicy
fallback time.Duration
want int
wantErr bool
}{
{
name: "zero fallback uses SDK default",
policy: RemoteTimeoutPolicy{Mode: RemoteTimeoutServerDefault},
fallback: 0,
},
{
name: "sub-second fallback rejected",
policy: RemoteTimeoutPolicy{Mode: RemoteTimeoutServerDefault},
fallback: 999 * time.Millisecond,
wantErr: true,
},
{
name: "fallback rounds up",
policy: RemoteTimeoutPolicy{Mode: RemoteTimeoutServerDefault},
fallback: 1500 * time.Millisecond,
want: 2,
},
{
name: "zero explicit uses SDK default",
policy: RemoteTimeoutPolicy{Mode: RemoteTimeoutExplicit},
},
{
name: "sub-second explicit rejected",
policy: RemoteTimeoutPolicy{Mode: RemoteTimeoutExplicit, Value: time.Millisecond},
wantErr: true,
},
{
name: "explicit rounds up",
policy: RemoteTimeoutPolicy{Mode: RemoteTimeoutExplicit, Value: 1500 * time.Millisecond},
want: 2,
},
{
name: "never rejected",
policy: RemoteTimeoutPolicy{Mode: RemoteTimeoutExplicit, Value: -time.Second},
wantErr: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := e2bTimeoutSeconds(tt.policy, tt.fallback)
if tt.wantErr {
require.Error(t, err)
return
}
require.NoError(t, err)
require.Equal(t, tt.want, got)
})
}
}
func TestE2BRemoteClientCreateRejectsSubSecondTimeout(t *testing.T) {
mock := newE2BMockServer(t)
client := newTestE2BRemoteClient(t, mock)
_, err := client.Create(context.Background(), RemoteCreateRequest{
TemplateID: "template-a",
Timeout: RemoteTimeoutPolicy{
Mode: RemoteTimeoutExplicit,
Value: time.Millisecond,
},
})
require.True(t, IsRemoteInvalidRequest(err))
require.Zero(t, mock.createCount.Load())
}
func TestE2BRemoteClientConnectAcrossClients(t *testing.T) {
mock := newE2BMockServer(t)
first := newTestE2BRemoteClient(t, mock)
handle, err := first.Create(context.Background(), RemoteCreateRequest{
TemplateID: "template-a",
Metadata: map[string]string{"owner": "session-1"},
})
require.NoError(t, err)
mock.sandboxes[handle.ID()]["state"] = "paused"
second := newTestE2BRemoteClient(t, mock)
reconnected, err := second.Connect(context.Background(), RemoteConnectRequest{SandboxID: handle.ID()})
require.NoError(t, err)
require.Equal(t, handle.ID(), reconnected.ID())
require.Nil(t, reconnected.Metadata())
require.Equal(t, handle.ID(), mock.connectID)
require.Equal(t, int32(1), mock.connectCount.Load())
require.Equal(t, float64(600), mock.connectBody["timeout"])
require.Equal(t, "running", mock.sandboxes[handle.ID()]["state"])
}
func TestE2BRemoteClientListFiltersByMetadataAndState(t *testing.T) {
mock := newE2BMockServer(t)
client := newTestE2BRemoteClient(t, mock)
ctx := context.Background()
_, err := client.Create(ctx, RemoteCreateRequest{
TemplateID: "template-a",
Metadata: map[string]string{"owner": "wanted"},
})
require.NoError(t, err)
// A matching paused sandbox proves the state filter is applied after
// metadata conversion.
paused, err := client.Create(ctx, RemoteCreateRequest{
TemplateID: "template-a",
Metadata: map[string]string{"owner": "wanted"},
})
require.NoError(t, err)
mock.sandboxes[paused.ID()]["state"] = "paused"
// A running sandbox with different metadata proves both filters are
// required for a match.
_, err = client.Create(ctx, RemoteCreateRequest{
TemplateID: "template-a",
Metadata: map[string]string{"owner": "other"},
})
require.NoError(t, err)
running, err := client.List(ctx, RemoteListFilter{
Metadata: map[string]string{"owner": "wanted"},
States: []RemoteSandboxState{RemoteStateRunning},
})
require.NoError(t, err)
require.Len(t, running, 1)
require.Equal(t, RemoteStateRunning, running[0].State)
require.Equal(t, map[string]string{"owner": "wanted"}, running[0].Metadata)
require.Equal(t, int32(1), mock.v2ListCount.Load())
require.Zero(t, mock.listCount.Load(), "List must use the V2 endpoint")
require.Equal(t, []string{"running"}, mock.v2Queries[0]["state"])
require.Equal(t, []string{"owner=wanted"}, mock.v2Queries[0]["metadata"])
require.Equal(t, "100", mock.v2Queries[0].Get("limit"))
pausedOnly, err := client.List(ctx, RemoteListFilter{
Metadata: map[string]string{"owner": "wanted"},
States: []RemoteSandboxState{RemoteStatePaused},
})
require.NoError(t, err)
require.Len(t, pausedOnly, 1)
require.Equal(t, RemoteStatePaused, pausedOnly[0].State)
require.Equal(t, []string{"paused"}, mock.v2Queries[1]["state"])
all, err := client.List(ctx, RemoteListFilter{
Metadata: map[string]string{"owner": "wanted"},
})
require.NoError(t, err)
require.Len(t, all, 2)
require.Equal(t, RemoteStateRunning, all[0].State)
require.Equal(t, RemoteStatePaused, all[1].State)
require.Empty(t, mock.v2Queries[2]["state"], "empty state filter must request V2 defaults")
}
func TestE2BRemoteClientListUsesOneServerMetadataFilterAndMatchesAllLocally(t *testing.T) {
mock := newE2BMockServer(t)
client := newTestE2BRemoteClient(t, mock)
ctx := context.Background()
_, err := client.Create(ctx, RemoteCreateRequest{
TemplateID: "template-a",
Metadata: map[string]string{"owner": "wanted", "scope": "alpha"},
})
require.NoError(t, err)
_, err = client.Create(ctx, RemoteCreateRequest{
TemplateID: "template-a",
Metadata: map[string]string{"owner": "wanted", "scope": "beta"},
})
require.NoError(t, err)
summaries, err := client.List(ctx, RemoteListFilter{
Metadata: map[string]string{"owner": "wanted", "scope": "alpha"},
})
require.NoError(t, err)
require.Len(t, summaries, 1)
require.Equal(t, map[string]string{"owner": "wanted", "scope": "alpha"}, summaries[0].Metadata)
require.Len(t, mock.v2Queries[0]["metadata"], 1)
}
func TestE2BRemoteClientListDoesNotClaimSameSessionAcrossTenants(t *testing.T) {
mock := newE2BMockServer(t)
client := newTestE2BRemoteClient(t, mock)
ctx := context.Background()
targetMetadata := map[string]string{
remoteMetadataTenantID: "tenant-a",
remoteMetadataSessionID: "shared-session",
remoteMetadataProvider: string(SandboxTypeE2B),
remoteMetadataBindingVersion: "1",
}
otherTenantMetadata := cloneMetadata(targetMetadata)
otherTenantMetadata[remoteMetadataTenantID] = "tenant-b"
target, err := client.Create(ctx, RemoteCreateRequest{
TemplateID: "template-a",
Metadata: targetMetadata,
})
require.NoError(t, err)
_, err = client.Create(ctx, RemoteCreateRequest{
TemplateID: "template-a",
Metadata: otherTenantMetadata,
})
require.NoError(t, err)
summaries, err := client.List(ctx, RemoteListFilter{Metadata: targetMetadata})
require.NoError(t, err)
require.Len(t, summaries, 1)
require.Equal(t, target.ID(), summaries[0].ID)
require.Equal(t, "tenant-a", summaries[0].Metadata[remoteMetadataTenantID])
require.Equal(
t,
[]string{remoteMetadataSessionID + "=shared-session"},
mock.v2Queries[0]["metadata"],
"server query must narrow by session only; complete ownership filtering is local",
)
}
func TestE2BRemoteClientListSkipsUnsafeServerMetadataAndMatchesLocally(t *testing.T) {
mock := newE2BMockServer(t)
client := newTestE2BRemoteClient(t, mock)
ctx := context.Background()
unsafeKey := "owner&+=%"
unsafeValue := "value&+=%"
_, err := client.Create(ctx, RemoteCreateRequest{
TemplateID: "template-a",
Metadata: map[string]string{unsafeKey: unsafeValue},
})
require.NoError(t, err)
_, err = client.Create(ctx, RemoteCreateRequest{
TemplateID: "template-a",
Metadata: map[string]string{unsafeKey: "other"},
})
require.NoError(t, err)
summaries, err := client.List(ctx, RemoteListFilter{
Metadata: map[string]string{unsafeKey: unsafeValue},
})
require.NoError(t, err)
require.Len(t, summaries, 1)
require.Equal(t, map[string]string{unsafeKey: unsafeValue}, summaries[0].Metadata)
require.Empty(t, mock.v2Queries[0]["metadata"])
}
func TestE2BRemoteClientListSkipsWireForUnmappableStates(t *testing.T) {
mock := newE2BMockServer(t)
client := newTestE2BRemoteClient(t, mock)
summaries, err := client.List(context.Background(), RemoteListFilter{
States: []RemoteSandboxState{RemoteStateTerminal, RemoteStateUnknown},
})
require.NoError(t, err)
require.Empty(t, summaries)
require.Zero(t, mock.v2ListCount.Load())
}
func TestE2BRemoteClientListMapsKnownStatesFromMixedFilter(t *testing.T) {
mock := newE2BMockServer(t)
mock.sandboxes["running"] = map[string]any{"sandboxID": "running", "state": "running"}
mock.sandboxes["paused"] = map[string]any{"sandboxID": "paused", "state": "paused"}
client := newTestE2BRemoteClient(t, mock)
summaries, err := client.List(context.Background(), RemoteListFilter{
States: []RemoteSandboxState{RemoteStateRunning, RemoteStateUnknown},
})
require.NoError(t, err)
require.Len(t, summaries, 1)
require.Equal(t, "running", summaries[0].ID)
require.Equal(t, int32(1), mock.v2ListCount.Load())
require.Equal(t, []string{"running"}, mock.v2Queries[0]["state"])
}
func TestE2BRemoteSummaryClonesMetadata(t *testing.T) {
source := map[string]string{"owner": "session-1"}
summary := e2bRemoteSummary(e2b.SandboxInfo{ID: "e2b-1", Metadata: source})
source["owner"] = "mutated-source"
require.Equal(t, "session-1", summary.Metadata["owner"])
summary.Metadata["owner"] = "mutated-summary"
require.Equal(t, "mutated-source", source["owner"])
}
func TestE2BRemoteClientGetUsesConnectAndInfo(t *testing.T) {
mock := newE2BMockServer(t)
client := newTestE2BRemoteClient(t, mock)
ctx := context.Background()
handle, err := client.Create(ctx, RemoteCreateRequest{TemplateID: "template-a"})
require.NoError(t, err)
summary, err := client.Get(ctx, handle.ID())
require.NoError(t, err)
require.Equal(t, handle.ID(), summary.ID)
require.Equal(t, RemoteStateRunning, summary.State)
// TemplateID only comes from the info endpoint; Connect alone cannot
// supply it, so its presence proves Get fetched full info by ID.
require.Equal(t, "template-a", summary.TemplateID)
require.Equal(t, int32(1), mock.connectCount.Load(), "Get reattaches via Connect")
require.Equal(t, int32(1), mock.infoCount.Load(), "Get reads info by ID")
require.Zero(t, mock.listCount.Load(), "Get must not scan the sandbox list")
require.Zero(t, mock.v2ListCount.Load(), "Get must not scan the V2 sandbox list")
// Unknown ID is a NotFound: Connect 404s and is normalized to NotFound.
_, err = client.Get(ctx, "no-such-sandbox")
require.True(t, IsRemoteNotFound(err))
}
func TestE2BRemoteClientGetResumesPausedSandbox(t *testing.T) {
mock := newE2BMockServer(t)
client := newTestE2BRemoteClient(t, mock)
handle, err := client.Create(context.Background(), RemoteCreateRequest{TemplateID: "template-a"})
require.NoError(t, err)
mock.sandboxes[handle.ID()]["state"] = "paused"
summary, err := client.Get(context.Background(), handle.ID())
require.NoError(t, err)
require.Equal(t, handle.ID(), summary.ID)
// Get reattaches via Connect, which wakes the paused sandbox, so the
// subsequent info read reports it running. This is intended: Get's only
// caller connects (and therefore wakes) the sandbox immediately after.
require.Equal(t, RemoteStateRunning, summary.State)
require.Equal(t, int32(1), mock.connectCount.Load())
require.Zero(t, mock.v2ListCount.Load(), "Get must not scan the V2 sandbox list")
}
func TestE2BRemoteClientListTraversesAllV2Pages(t *testing.T) {
mock := newE2BMockServer(t)
for index := 0; index < 101; index++ {
id := fmt.Sprintf("sandbox-%03d", index)
mock.sandboxes[id] = map[string]any{
"sandboxID": id,
"templateID": "template-a",
"state": "running",
"startedAt": time.Now().UTC().Format(time.RFC3339),
}
}
client := newTestE2BRemoteClient(t, mock)
summaries, err := client.List(context.Background(), RemoteListFilter{})
require.NoError(t, err)
require.Len(t, summaries, 101)
require.Equal(t, int32(2), mock.v2ListCount.Load())
// Get is a direct by-ID lookup (Connect+info); it never touches the list.
mock.v2ListCount.Store(0)
summary, err := client.Get(context.Background(), "sandbox-100")
require.NoError(t, err)
require.Equal(t, "sandbox-100", summary.ID)
require.Zero(t, mock.v2ListCount.Load(), "Get must not scan the V2 sandbox list")
require.Equal(t, int32(1), mock.connectCount.Load())
require.Equal(t, int32(1), mock.infoCount.Load())
}
func TestE2BRemoteClientListRejectsRepeatedV2NextToken(t *testing.T) {
mock := newE2BMockServer(t)
mock.repeatV2NextToken = true
mock.sandboxes["sandbox-1"] = map[string]any{
"sandboxID": "sandbox-1",
"templateID": "template-a",
"state": "running",
"startedAt": time.Now().UTC().Format(time.RFC3339),
}
client := newTestE2BRemoteClient(t, mock)
_, err := client.List(context.Background(), RemoteListFilter{})
require.Error(t, err)
var remoteErr *RemoteError
require.ErrorAs(t, err, &remoteErr)
require.Equal(t, RemoteErrorKindInternal, remoteErr.Kind)
require.Equal(t, "List", remoteErr.Op)
require.Equal(t, int32(2), mock.v2ListCount.Load())
}
func TestE2BRemoteClientListRejectsUnsafeV2NextTokenBeforeNextRequest(t *testing.T) {
mock := newE2BMockServer(t)
mock.unsafeV2NextToken = "next&token"
mock.sandboxes["sandbox-1"] = map[string]any{
"sandboxID": "sandbox-1",
"state": "running",
}
client := newTestE2BRemoteClient(t, mock)
_, err := client.List(context.Background(), RemoteListFilter{})
require.Error(t, err)
var remoteErr *RemoteError
require.ErrorAs(t, err, &remoteErr)
require.Equal(t, RemoteErrorKindInternal, remoteErr.Kind)
require.Equal(t, "List", remoteErr.Op)
require.Contains(t, remoteErr.Error(), "unsafe")
require.Equal(t, int32(1), mock.v2ListCount.Load())
}
func TestE2BRemoteClientDeleteAcrossClients(t *testing.T) {
mock := newE2BMockServer(t)
creator := newTestE2BRemoteClient(t, mock)
ctx := context.Background()
handle, err := creator.Create(ctx, RemoteCreateRequest{TemplateID: "template-a"})
require.NoError(t, err)
deleter := newTestE2BRemoteClient(t, mock)
require.NoError(t, deleter.Delete(ctx, handle.ID()))
require.Equal(t, handle.ID(), mock.connectID)
require.Equal(t, int32(1), mock.connectCount.Load())
require.Equal(t, handle.ID(), mock.deleteID)
require.Equal(t, int32(1), mock.deleteCount.Load())
err = deleter.Delete(ctx, handle.ID())
require.True(t, IsRemoteNotFound(err))
require.Equal(t, int32(1), mock.deleteCount.Load())
}
func TestE2BRemoteClientFilesystemOperationsRejectEmptyPaths(t *testing.T) {
client := newTestE2BRemoteClient(t, newE2BMockServer(t))
handle := &e2bRemoteHandle{sandbox: &e2b.Sandbox{ID: "e2b-1"}}
_, err := client.ListDir(context.Background(), handle, " ")
require.True(t, IsRemoteInvalidRequest(err))
err = client.MakeDir(context.Background(), handle, " ")
require.True(t, IsRemoteInvalidRequest(err))
err = client.Remove(context.Background(), handle, " ")
require.True(t, IsRemoteInvalidRequest(err))
_, err = client.Stat(context.Background(), handle, " ")
require.True(t, IsRemoteInvalidRequest(err))
}
func TestE2BRemoteClientRejectsForeignHandle(t *testing.T) {
client := newTestE2BRemoteClient(t, newE2BMockServer(t))
_, err := client.ReadFile(
context.Background(),
&contractHandle{id: "cube-1", provider: SandboxTypeCube},
"/workspace/file",
)
require.True(t, IsRemoteInvalidRequest(err))
}
func TestNormalizeE2BState(t *testing.T) {
tests := map[string]RemoteSandboxState{
"": RemoteStateUnknown,
"running": RemoteStateRunning,
"paused": RemoteStatePaused,
"pausing": RemoteStateTransitioning,
"resuming": RemoteStateTransitioning,
"pending": RemoteStateTransitioning,
"killed": RemoteStateTerminal,
"terminated": RemoteStateTerminal,
"deleted": RemoteStateTerminal,
"failed": RemoteStateTerminal,
"stopped": RemoteStateTerminal,
"error": RemoteStateTerminal,
"weird-state": RemoteStateUnknown,
}
for raw, want := range tests {
require.Equalf(t, want, normalizeE2BState(raw), "state %q", raw)
}
}
func TestNormalizeE2BError(t *testing.T) {
tests := []struct {
name string
op string
err error
kind RemoteErrorKind
}{
{"sandbox not found", "Get", &e2b.SandboxNotFoundError{SandboxID: "e2b-1"}, RemoteErrorKindNotFound},
{"file not found", "ReadFile", &e2b.FileNotFoundError{Path: "/x"}, RemoteErrorKindNotFound},
{"template not found", "Create", &e2b.TemplateNotFoundError{TemplateID: "t"}, RemoteErrorKindInvalidRequest},
{"unauthorized", "Health", &e2b.Error{StatusCode: http.StatusUnauthorized}, RemoteErrorKindAuthentication},
{"rate limited", "Create", &e2b.Error{StatusCode: http.StatusTooManyRequests}, RemoteErrorKindCapacity},
{"gone", "Get", &e2b.Error{StatusCode: http.StatusGone}, RemoteErrorKindTerminal},
{"unavailable", "List", &e2b.Error{StatusCode: http.StatusBadGateway}, RemoteErrorKindUnavailable},
{"deadline", "Exec", context.DeadlineExceeded, RemoteErrorKindTimeout},
{"network timeout", "List", e2bTestNetError{timeout: true}, RemoteErrorKindTimeout},
{"network unavailable", "List", e2bTestNetError{}, RemoteErrorKindUnavailable},
{"connect canceled", "List", connect.NewError(connect.CodeCanceled, errors.New("canceled")), RemoteErrorKindTimeout},
{"connect deadline", "List", connect.NewError(connect.CodeDeadlineExceeded, errors.New("deadline")), RemoteErrorKindTimeout},
{"connect unavailable", "List", connect.NewError(connect.CodeUnavailable, errors.New("unavailable")), RemoteErrorKindUnavailable},
{"connect unauthenticated", "List", connect.NewError(connect.CodeUnauthenticated, errors.New("unauthenticated")), RemoteErrorKindAuthentication},
{"connect permission denied", "List", connect.NewError(connect.CodePermissionDenied, errors.New("permission denied")), RemoteErrorKindAuthentication},
{"connect resource exhausted", "List", connect.NewError(connect.CodeResourceExhausted, errors.New("resource exhausted")), RemoteErrorKindCapacity},
{"connect invalid argument", "List", connect.NewError(connect.CodeInvalidArgument, errors.New("invalid argument")), RemoteErrorKindInvalidRequest},
{"connect out of range", "List", connect.NewError(connect.CodeOutOfRange, errors.New("out of range")), RemoteErrorKindInvalidRequest},
{"connect not found", "List", connect.NewError(connect.CodeNotFound, errors.New("not found")), RemoteErrorKindNotFound},
{"connect already exists", "List", connect.NewError(connect.CodeAlreadyExists, errors.New("already exists")), RemoteErrorKindConflict},
{"connect aborted", "List", connect.NewError(connect.CodeAborted, errors.New("aborted")), RemoteErrorKindConflict},
{"connect failed precondition", "List", connect.NewError(connect.CodeFailedPrecondition, errors.New("failed precondition")), RemoteErrorKindConflict},
{"connect unimplemented", "List", connect.NewError(connect.CodeUnimplemented, errors.New("unimplemented")), RemoteErrorKindUnsupported},
{"connect internal", "List", connect.NewError(connect.CodeInternal, errors.New("internal")), RemoteErrorKindInternal},
{"connect data loss", "List", connect.NewError(connect.CodeDataLoss, errors.New("data loss")), RemoteErrorKindInternal},
{"connect unknown", "List", connect.NewError(connect.CodeUnknown, errors.New("unknown")), RemoteErrorKindInternal},
{"unknown", "List", errors.New("mystery"), RemoteErrorKindInternal},
{"delete snapshot in use", "DeleteSnapshot", &e2b.Error{
StatusCode: http.StatusBadRequest,
Message: "cannot delete template 'tpl-1' because there are paused sandboxes using it",
}, RemoteErrorKindConflict},
{"delete snapshot bad id", "DeleteSnapshot", &e2b.Error{
StatusCode: http.StatusBadRequest,
Message: "invalid snapshot id",
}, RemoteErrorKindInvalidRequest},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := normalizeE2BError(tt.op, tt.err)
var remoteErr *RemoteError
require.ErrorAs(t, err, &remoteErr)
require.Equal(t, tt.kind, remoteErr.Kind)
require.Equal(t, SandboxTypeE2B, remoteErr.Provider)
require.ErrorIs(t, err, tt.err)
})
}
t.Run("context canceled remains discoverable", func(t *testing.T) {
err := normalizeE2BError("List", fmt.Errorf("wrapped: %w", context.Canceled))
require.ErrorIs(t, err, context.Canceled)
})
}