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) }) }