Files
xinmi/internal/sandbox/e2b_remote_client.go
T
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

1338 lines
43 KiB
Go

// Package sandbox: E2B adapter for the provider-neutral RemoteSandboxClient.
//
// E2BRemoteClient wraps go-e2b and keeps provider-specific configuration,
// lifecycle, access-token, and filesystem details behind RemoteSandboxClient.
package sandbox
import (
"context"
"errors"
"fmt"
"net"
"net/http"
"net/url"
"sort"
"strings"
"time"
"connectrpc.com/connect"
e2b "github.com/matiasinsaurralde/go-e2b"
"github.com/Tencent/WeKnora/internal/logger"
)
// E2BRemoteClient implements RemoteSandboxClient on top of the go-e2b client.
type E2BRemoteClient struct {
client *e2b.Client
inboundTokens *InboundTokenRegistry
templateID string
timeout time.Duration
}
// NewE2BRemoteClient builds an E2B-backed RemoteSandboxClient from Config.
// The API key is required; the caller may leave APIURL / SandboxDomain empty
// to fall back to the SDK's built-in defaults.
func NewE2BRemoteClient(cfg *Config) (*E2BRemoteClient, error) {
return NewE2BRemoteClientWithTransport(cfg, nil)
}
// NewE2BRemoteClientWithTransport builds the client with an injected HTTP
// transport. Per-tenant clients are constructed per request, so sharing one
// transport across tenants is what preserves connection pooling: transports
// pool per host:port and the API key travels in per-request headers, making
// the sharing both safe and effective. A nil transport uses the http default.
func NewE2BRemoteClientWithTransport(
cfg *Config,
transport *http.Transport,
) (*E2BRemoteClient, error) {
if transport == nil {
return newE2BRemoteClient(cfg, nil, NewInboundTokenRegistry())
}
return newE2BRemoteClient(cfg, transport, NewInboundTokenRegistry())
}
// NewE2BRemoteClientWithPool builds the client on top of the shared gateway
// pool. It is what self-hosted E2B-compatible control planes need: the pool
// keeps control-plane traffic on the process-wide transport while dialling
// data-plane traffic at the configured gateway (see gateway_transport.go).
// A nil pool falls back to the SDK defaults.
func NewE2BRemoteClientWithPool(
cfg *Config,
pool *SandboxGatewayTransportPool,
) (*E2BRemoteClient, error) {
if pool == nil {
return newE2BRemoteClient(cfg, nil, NewInboundTokenRegistry())
}
return newE2BRemoteClient(cfg, pool.RoundTripperFor(cfg), pool.InboundTokens())
}
func newE2BRemoteClient(
cfg *Config,
transport http.RoundTripper,
inboundTokens *InboundTokenRegistry,
) (*E2BRemoteClient, error) {
if cfg == nil {
return nil, errors.New("e2b remote client config is required")
}
if strings.TrimSpace(cfg.E2BAPIKey) == "" {
return nil, errors.New("E2BAPIKey is required for the E2B backend")
}
timeout := cfg.E2BHTTPTimeout
if timeout <= 0 {
timeout = DefaultE2BHTTPTimeout
}
// Attach the inbound-token injector even when the caller did not go
// through the gateway pool. go-e2b stores TrafficAccessToken but never
// sends the header; without this wrap, NewE2BRemoteClient / WithTransport
// would register tokens that no HTTP stack consults. A pool-built split
// that already owns the same registry is left as-is.
transport = attachInboundTokenTransport(transport, inboundTokens)
// Every E2B client speaks to envd through the compatibility shim, whether
// or not a gateway is configured: the two details it rewrites belong to the
// envd protocol itself, not to any one deployment. See envd_compat_transport.go.
httpClient := &http.Client{
Timeout: timeout,
Transport: NewEnvdCompatTransport(transport, DefaultSandboxExecUser),
}
client, err := e2b.NewClient(e2b.ClientConfig{
APIKey: cfg.E2BAPIKey,
APIBaseURL: strings.TrimSpace(cfg.E2BAPIURL),
SandboxDomain: strings.TrimSpace(cfg.E2BSandboxDomain),
HTTPClient: httpClient,
})
if err != nil {
return nil, fmt.Errorf("build e2b client: %w", err)
}
ttl := cfg.E2BSandboxTTL
if ttl <= 0 {
ttl = DefaultE2BSandboxTTL
}
return &E2BRemoteClient{
client: client,
inboundTokens: inboundTokens,
templateID: strings.TrimSpace(cfg.E2BTemplate),
timeout: ttl,
}, nil
}
// e2bRemoteHandle is the RemoteSandboxHandle E2B returns. It carries the
// *e2b.Sandbox so subsequent envd calls can reuse its access token.
type e2bRemoteHandle struct {
sandbox *e2b.Sandbox
metadata map[string]string
}
func (h *e2bRemoteHandle) ID() string {
if h == nil || h.sandbox == nil {
return ""
}
return h.sandbox.ID
}
func (h *e2bRemoteHandle) Provider() RemoteProvider { return SandboxTypeE2B }
func (h *e2bRemoteHandle) Metadata() map[string]string {
if h == nil {
return nil
}
return cloneMetadata(h.metadata)
}
// TrafficAccessToken implements RemoteInboundTokenCarrier. E2B issues it only
// in the create response; go-e2b keeps the field but never sends the header,
// so both persisting it and attaching it are WeKnora's job.
func (h *e2bRemoteHandle) TrafficAccessToken() string {
if h == nil || h.sandbox == nil {
return ""
}
return h.sandbox.TrafficAccessToken
}
// --- RemoteSandboxClient ------------------------------------------------------
func (c *E2BRemoteClient) Provider() RemoteProvider { return SandboxTypeE2B }
func (c *E2BRemoteClient) Capabilities() RemoteSandboxCapabilities {
return RemoteSandboxCapabilities{
SupportsReconnect: true,
SupportsMetadata: true,
SupportsListSandboxes: true,
SupportsPauseResume: true,
SupportsTimeoutRefresh: true,
SupportsFilesystemEnumeration: true,
// E2B stores snapshots as templates, so a snapshot ID can be handed
// straight back as CreateOptions.TemplateID.
SupportsSnapshots: true,
// E2B has no named-volume mount API that WeKnora can use; advertising
// it would let a workspace configure a mount that never appears.
SupportsVolumes: false,
}
}
// Health probes the control plane by listing sandboxes. The protocol has no
// dedicated health endpoint, and a list is the smallest authenticated call
// that detects a bad API key or a dead API.
//
// It deliberately uses the v2 listing rather than the legacy one: v2 is what
// every other call in this client already depends on, and E2B-compatible
// control planes (Agent-Sandbox, for one) implement only that one — probing
// the legacy path would report a perfectly healthy backend as unreachable.
func (c *E2BRemoteClient) Health(ctx context.Context) error {
if _, err := c.client.ListSandboxesV2(ctx, e2b.WithSandboxLimit(1)); err != nil {
return normalizeE2BError("Health", err)
}
return nil
}
func (c *E2BRemoteClient) ListTemplates(ctx context.Context) ([]RemoteTemplate, error) {
items, err := c.client.ListTemplates(ctx)
if err != nil {
return nil, normalizeE2BError("ListTemplates", err)
}
result := make([]RemoteTemplate, 0, len(items))
for _, item := range items {
name := item.TemplateID
if len(item.Names) > 0 && strings.TrimSpace(item.Names[0]) != "" {
name = strings.TrimSpace(item.Names[0])
} else if len(item.Aliases) > 0 && strings.TrimSpace(item.Aliases[0]) != "" {
name = strings.TrimSpace(item.Aliases[0])
}
standard := isStandardTemplate(name)
for _, candidate := range append(append([]string(nil), item.Aliases...), item.Names...) {
if isStandardTemplate(candidate) {
standard = true
break
}
}
status := normalizeE2BTemplateBuildStatus(item.BuildStatus)
// E2B's template list keeps reporting a pending build status after the
// template already became spawnable, so reconcile our standard template
// against the build endpoints before telling the UI to keep waiting
// forever. Limit these extra requests to the standard template; polling
// every public template would turn one catalog refresh into an
// unbounded N+1 request pattern.
if standard && isE2BTemplateBuildPending(status) {
if reconciled := c.reconcileE2BTemplateStatus(ctx, item); reconciled != "" {
status = reconciled
}
}
result = append(result, RemoteTemplate{
ID: item.TemplateID,
Name: name,
Status: status,
Version: item.EnvdVersion,
CreatedAt: item.CreatedAt,
UpdatedAt: item.UpdatedAt,
Standard: standard,
})
}
return result, nil
}
// reconcileE2BTemplateStatus resolves the real build state of a template whose
// list entry still looks pending, by asking the per-build endpoint that owns
// that answer.
//
// Only the template's *current* build counts as evidence. E2B spawns from that
// build alone, so neither an older successful build nor a non-zero spawn count
// proves the template can boot now — both happily coexist with a template that
// answers every sandbox creation with a 404. It returns an empty string when
// nothing more authoritative than the list status could be established.
func (c *E2BRemoteClient) reconcileE2BTemplateStatus(
ctx context.Context,
item e2b.TemplateDetail,
) string {
templateID := strings.TrimSpace(item.TemplateID)
buildID := strings.TrimSpace(item.BuildID)
if templateID == "" {
return ""
}
// buildID is "the last successful build", so the all-zero UUID means the
// provider sees none under the tag it resolves. When builds did finish, the
// template is not building at all — its builds are untagged, and no amount
// of waiting will change that.
if isBlankE2BUUID(buildID) {
if item.BuildCount > 0 && c.hasSuccessfulE2BBuild(ctx, templateID) {
return TemplateStatusUntagged
}
return ""
}
build, err := c.client.GetBuildStatus(ctx, templateID, buildID)
if err != nil {
logger.Warnf(ctx,
"e2b build status lookup failed for template %s build %s: %v",
templateID, buildID, err,
)
return ""
}
if isE2BTemplateBuildPending(build.Status) {
return ""
}
reconciled := normalizeE2BTemplateBuildStatus(build.Status)
if reconciled == "" {
return ""
}
logger.Debugf(ctx,
"e2b template %s list status %q reconciled to %q from build %s",
templateID, item.BuildStatus, reconciled, buildID,
)
return reconciled
}
// hasSuccessfulE2BBuild reports whether the template's build history contains a
// finished build. It separates "the first build is still running" from "builds
// succeeded but none is reachable under the default tag".
func (c *E2BRemoteClient) hasSuccessfulE2BBuild(ctx context.Context, templateID string) bool {
result, err := c.client.GetTemplate(ctx, templateID, e2b.WithTemplateBuildsLimit(100))
if err != nil {
logger.Warnf(ctx, "e2b template build history lookup failed for %s: %v", templateID, err)
return false
}
for _, build := range result.Template.Builds {
if normalizeE2BTemplateBuildStatus(build.Status) == "ready" {
return true
}
}
return false
}
// isBlankE2BUUID reports whether id carries no build reference. E2B returns the
// all-zero UUID, not an empty string, for a template without a build.
func isBlankE2BUUID(id string) bool {
trimmed := strings.TrimSpace(id)
if trimmed == "" {
return true
}
for _, char := range trimmed {
if char != '0' && char != '-' {
return false
}
}
return true
}
func normalizeE2BTemplateBuildStatus(status string) string {
switch strings.ToLower(strings.TrimSpace(status)) {
case "ready", "success", "succeeded", "complete", "completed":
return "ready"
case "building", "processing":
return "building"
case "waiting", "queued", "pending", "uploaded":
return "waiting"
case "error", "failed", "failure", "cancelled", "canceled":
return "error"
default:
return strings.ToLower(strings.TrimSpace(status))
}
}
func isE2BTemplateBuildPending(status string) bool {
switch normalizeE2BTemplateBuildStatus(status) {
case "building", "waiting":
return true
default:
return false
}
}
// EnsureStandardTemplate returns the cluster's WeKnora template, building it
// when absent. A failed or untagged template is not returned as is: it can
// never spawn a sandbox, so it falls through to the build below. E2B resolves
// the build by name, so that is a rebuild of the same template rather than a
// second entry in the catalog.
func (c *E2BRemoteClient) EnsureStandardTemplate(ctx context.Context) (*RemoteTemplate, error) {
items, err := c.ListTemplates(ctx)
if err != nil {
return nil, err
}
for i := range items {
if items[i].Standard && !IsTemplateBuildFailed(items[i].Status) {
return &items[i], nil
}
}
return c.buildStandardTemplate(ctx)
}
// ReplaceStandardTemplate starts a new WeKnora-template build from the current
// spec. E2B resolves builds by name, so this is often a rebuild of the same
// ID. The previous template stays listed until the caller has persisted a
// spawnable replacement and called DeleteSupersededStandardTemplates: deleting
// first left stored template_ids pointing at a missing template, and retrying
// a refused rebuild by deleting the live ID did the same.
func (c *E2BRemoteClient) ReplaceStandardTemplate(ctx context.Context) (*RemoteTemplate, error) {
return c.buildStandardTemplate(ctx)
}
// DeleteSupersededStandardTemplates drops WeKnora templates other than keepID.
func (c *E2BRemoteClient) DeleteSupersededStandardTemplates(ctx context.Context, keepID string) error {
keepID = strings.TrimSpace(keepID)
if keepID == "" {
return e2bInvalidRequest("DeleteSupersededStandardTemplates", "template ID is required", nil)
}
items, err := c.ListTemplates(ctx)
if err != nil {
return err
}
for _, item := range items {
if !item.Standard || strings.TrimSpace(item.ID) == "" || item.ID == keepID {
continue
}
logger.Infof(ctx, "e2b deleting superseded standard template %s", item.ID)
if err := c.client.DeleteTemplate(ctx, item.ID); err != nil {
var notFound *e2b.TemplateNotFoundError
if !errors.As(err, &notFound) {
logger.Warnf(ctx, "e2b delete of replaced template %s failed: %v", item.ID, err)
}
}
}
return nil
}
func (c *E2BRemoteClient) buildStandardTemplate(ctx context.Context) (*RemoteTemplate, error) {
builder := e2b.NewTemplate().FromImage(DefaultDockerImage)
build, err := builder.BuildInBackground(ctx, c.client, e2b.BuildConfig{
Name: StandardTemplateName,
// Creating a sandbox from a plain template name or ID resolves the
// "default" tag, so a build tagged anything else is unreachable: E2B
// answers every creation with "tag 'default' does not exist for
// template ...". Naming the tag explicitly keeps that contract visible
// instead of relying on the server-side default.
Tags: []string{DefaultE2BTemplateTag},
CPUCount: 2,
MemoryMB: 2048,
})
if err != nil {
return nil, normalizeE2BError("EnsureStandardTemplate", err)
}
return &RemoteTemplate{
ID: build.TemplateID,
Name: StandardTemplateName,
Status: "building",
Image: DefaultDockerImage,
Standard: true,
}, nil
}
func (c *E2BRemoteClient) Create(
ctx context.Context,
request RemoteCreateRequest,
) (RemoteSandboxHandle, error) {
template := strings.TrimSpace(request.TemplateID)
if template == "" {
template = c.templateID
}
if template == "" {
return nil, e2bInvalidRequest("Create", "template ID is required", nil)
}
timeoutSeconds, err := e2bTimeoutSeconds(request.Timeout, c.timeout)
if err != nil {
return nil, e2bInvalidRequest("Create", err.Error(), err)
}
action := request.Timeout.Action
if action == "" {
action = RemoteOnTimeoutKill
}
if action != RemoteOnTimeoutKill && action != RemoteOnTimeoutPause {
return nil, e2bInvalidRequest(
"Create",
fmt.Sprintf("unsupported timeout action %q", action),
nil,
)
}
if request.Timeout.AutoResume && action != RemoteOnTimeoutPause {
return nil, e2bInvalidRequest(
"Create",
"auto resume requires pause on timeout",
nil,
)
}
policy := request.Network
if policy.AllowInternetAccess == nil {
defaultOn := true
policy.AllowInternetAccess = &defaultOn
}
// Deliberately the opposite of E2B's own default (which is public).
// Closing inbound requires Secure=true, pinned below, and makes the
// create response carry a traffic access token that the lifecycle
// persists — go-e2b stores that token but never sends it, so
// gatewaySplitTransport is what puts it on data-plane requests.
// Do not change this to true.
if policy.AllowPublicTraffic == nil {
defaultClosed := false
policy.AllowPublicTraffic = &defaultClosed
}
config := e2b.SandboxConfig{
Template: template,
Timeout: timeoutSeconds,
EnvVars: cloneMetadata(request.EnvVars),
Metadata: cloneMetadata(request.Metadata),
Secure: true,
AllowInternetAccess: policy.AllowInternetAccess,
Network: &e2b.NetworkConfig{
AllowPublicTraffic: policy.AllowPublicTraffic,
AllowOut: append([]string(nil), policy.AllowOut...),
DenyOut: append([]string(nil), policy.DenyOut...),
Rules: toE2BRequestRules(policy.E2BHostRules),
},
AutoPause: action == RemoteOnTimeoutPause,
VolumeMounts: toE2BVolumeMounts(request.VolumeMounts),
}
if request.Timeout.AutoResume {
autoPauseMemory := true
config.AutoPauseMemory = &autoPauseMemory
config.AutoResume = &e2b.AutoResumeConfig{Enabled: true}
}
sandbox, err := c.client.NewSandbox(ctx, config)
if err != nil {
return nil, normalizeE2BError("Create", err)
}
if sandbox == nil || strings.TrimSpace(sandbox.ID) == "" {
return nil, NewRemoteError(
SandboxTypeE2B, "Create", RemoteErrorKindInternal,
"e2b returned an empty sandbox handle", nil,
)
}
// go-e2b keeps this token but never sends it, so register it for the
// data-plane transport to attach.
c.inboundTokens.Put(sandbox.ID, sandbox.TrafficAccessToken)
return &e2bRemoteHandle{
sandbox: sandbox,
metadata: cloneMetadata(request.Metadata),
}, nil
}
func (c *E2BRemoteClient) Connect(
ctx context.Context,
request RemoteConnectRequest,
) (RemoteSandboxHandle, error) {
sandboxID := strings.TrimSpace(request.SandboxID)
if sandboxID == "" {
return nil, e2bInvalidRequest("Connect", "sandbox ID is required", nil)
}
timeoutSeconds, err := e2bTimeoutSeconds(
RemoteTimeoutPolicy{Mode: RemoteTimeoutServerDefault},
c.timeout,
)
if err != nil {
return nil, e2bInvalidRequest("Connect", err.Error(), err)
}
sandbox, err := c.client.Connect(ctx, sandboxID, timeoutSeconds)
if err != nil {
return nil, normalizeE2BError("Connect", err)
}
if sandbox == nil || strings.TrimSpace(sandbox.ID) == "" ||
sandbox.ID != sandboxID {
return nil, NewRemoteError(
SandboxTypeE2B, "Connect", RemoteErrorKindInternal,
"e2b returned a mismatched sandbox handle", nil,
)
}
if sandbox.TrafficAccessToken == "" {
sandbox.TrafficAccessToken = request.TrafficAccessToken
}
c.inboundTokens.Put(sandbox.ID, sandbox.TrafficAccessToken)
return &e2bRemoteHandle{sandbox: sandbox}, nil
}
// Get returns a single sandbox summary by ID. E2B's control plane exposes no
// read-only "fetch sandbox by ID" endpoint, so Get reattaches to the sandbox
// via Connect and reads its full metadata through the per-sandbox info
// endpoint (GET /sandboxes/{id}). This mirrors the Cube adapter's
// Connect+GetInfo shape and yields a strongly-consistent, O(1) lookup with a
// clean NotFound, instead of scanning the whole account-wide sandbox list
// (which is O(N) and can return a stale NotFound for a just-created sandbox).
//
// Get's only caller is the lifecycle coordinator's connectBinding pre-check,
// which Connects — and therefore wakes — the sandbox immediately afterwards.
// Resuming a paused sandbox here is thus the intended behaviour, not a side
// effect to avoid.
func (c *E2BRemoteClient) Get(
ctx context.Context,
sandboxID string,
) (*RemoteSandboxSummary, error) {
if strings.TrimSpace(sandboxID) == "" {
return nil, e2bInvalidRequest("Get", "sandbox ID is required", nil)
}
timeoutSeconds, err := e2bTimeoutSeconds(
RemoteTimeoutPolicy{Mode: RemoteTimeoutServerDefault},
c.timeout,
)
if err != nil {
return nil, e2bInvalidRequest("Get", err.Error(), err)
}
sandbox, err := c.client.Connect(ctx, sandboxID, timeoutSeconds)
if err != nil {
return nil, normalizeE2BError("Get", err)
}
if sandbox == nil || strings.TrimSpace(sandbox.ID) == "" ||
sandbox.ID != sandboxID {
return nil, NewRemoteError(
SandboxTypeE2B, "Get", RemoteErrorKindInternal,
"e2b returned a mismatched sandbox handle", nil,
)
}
info, err := sandbox.InfoWithContext(ctx)
if err != nil {
return nil, normalizeE2BError("Get", err)
}
if info == nil {
return nil, NewRemoteError(
SandboxTypeE2B, "Get", RemoteErrorKindNotFound,
"sandbox not found", nil,
)
}
summary := e2bRemoteSummary(*info)
return &summary, nil
}
func (c *E2BRemoteClient) List(
ctx context.Context,
filter RemoteListFilter,
) ([]RemoteSandboxSummary, error) {
if len(filter.States) > 0 && len(e2bListStates(filter.States)) == 0 {
return []RemoteSandboxSummary{}, nil
}
infos, err := c.listSandboxesV2(ctx, "List", filter)
if err != nil {
return nil, err
}
result := make([]RemoteSandboxSummary, 0, len(infos))
for _, info := range infos {
converted := e2bRemoteSummary(info)
if !metadataMatches(converted.Metadata, filter.Metadata) ||
!StateMatches(converted.State, filter.States) {
continue
}
result = append(result, converted)
}
return result, nil
}
func (c *E2BRemoteClient) listSandboxesV2(
ctx context.Context,
op string,
filter RemoteListFilter,
) ([]e2b.SandboxInfo, error) {
baseOptions := []e2b.ListSandboxesV2Option{e2b.WithSandboxLimit(100)}
if serverMetadata := e2bServerMetadataFilter(filter.Metadata); len(serverMetadata) > 0 {
baseOptions = append(baseOptions, e2b.WithSandboxMetadata(serverMetadata))
}
if states := e2bListStates(filter.States); len(states) > 0 {
baseOptions = append(baseOptions, e2b.WithSandboxState(states...))
}
var sandboxes []e2b.SandboxInfo
nextToken := ""
seenTokens := make(map[string]struct{})
for {
options := append([]e2b.ListSandboxesV2Option(nil), baseOptions...)
if nextToken != "" {
options = append(options, e2b.WithSandboxNextToken(nextToken))
}
page, err := c.client.ListSandboxesV2(ctx, options...)
if err != nil {
return nil, normalizeE2BError(op, err)
}
if page == nil {
return nil, NewRemoteError(
SandboxTypeE2B,
op,
RemoteErrorKindInternal,
"e2b returned an empty sandbox list page",
nil,
)
}
sandboxes = append(sandboxes, page.Sandboxes...)
if page.NextToken == "" {
return sandboxes, nil
}
if !e2bQuerySafe(page.NextToken) {
return nil, NewRemoteError(
SandboxTypeE2B,
op,
RemoteErrorKindInternal,
"e2b returned an unsafe sandbox list pagination token",
nil,
)
}
if _, repeated := seenTokens[page.NextToken]; repeated {
return nil, NewRemoteError(
SandboxTypeE2B,
op,
RemoteErrorKindInternal,
"e2b returned a repeated sandbox list pagination token",
nil,
)
}
seenTokens[page.NextToken] = struct{}{}
nextToken = page.NextToken
}
}
// E2B's V2 endpoint accepts one metadata query parameter even though the SDK
// option accepts a map. Send the most selective stable ownership key available;
// List still applies the complete metadata filter locally before returning.
func e2bServerMetadataFilter(metadata map[string]string) map[string]string {
if len(metadata) == 0 {
return nil
}
for _, key := range []string{
remoteMetadataSessionID,
remoteMetadataTenantID,
remoteMetadataProvider,
remoteMetadataBindingVersion,
remoteMetadataConfigID,
} {
if value, ok := metadata[key]; ok {
return e2bSafeMetadataFilter(key, value)
}
}
keys := make([]string, 0, len(metadata))
for key := range metadata {
keys = append(keys, key)
}
sort.Strings(keys)
key := keys[0]
return e2bSafeMetadataFilter(key, metadata[key])
}
func e2bSafeMetadataFilter(key, value string) map[string]string {
if !e2bQuerySafe(key) || !e2bQuerySafe(value) {
return nil
}
return map[string]string{key: value}
}
func e2bQuerySafe(value string) bool {
return url.QueryEscape(value) == value
}
func e2bListStates(states []RemoteSandboxState) []string {
result := make([]string, 0, len(states))
for _, state := range states {
switch state {
case RemoteStateRunning:
result = append(result, "running")
case RemoteStatePaused:
result = append(result, "paused")
}
}
return result
}
func (c *E2BRemoteClient) Delete(ctx context.Context, sandboxID string) error {
if strings.TrimSpace(sandboxID) == "" {
return e2bInvalidRequest("Delete", "sandbox ID is required", nil)
}
timeoutSeconds, err := e2bTimeoutSeconds(
RemoteTimeoutPolicy{Mode: RemoteTimeoutServerDefault},
c.timeout,
)
if err != nil {
return e2bInvalidRequest("Delete", err.Error(), err)
}
sandbox, err := c.client.Connect(ctx, sandboxID, timeoutSeconds)
if err != nil {
return normalizeE2BError("Delete", err)
}
if sandbox == nil || strings.TrimSpace(sandbox.ID) == "" ||
sandbox.ID != sandboxID {
return NewRemoteError(
SandboxTypeE2B, "Delete", RemoteErrorKindInternal,
"e2b returned a mismatched sandbox handle", nil,
)
}
if err := sandbox.CloseWithContext(ctx); err != nil {
return normalizeE2BError("Delete", err)
}
c.inboundTokens.Delete(sandboxID)
return nil
}
func (c *E2BRemoteClient) Exec(
ctx context.Context,
handle RemoteSandboxHandle,
request RemoteExecRequest,
) (*RemoteExecResult, error) {
sandbox, err := e2bHandleSandbox("Exec", handle)
if err != nil {
return nil, err
}
if strings.TrimSpace(request.Command) == "" {
return nil, e2bInvalidRequest("Exec", "command is required", nil)
}
if request.Shell && len(request.Args) != 0 {
return nil, e2bInvalidRequest(
"Exec", "shell execution cannot include argv arguments", nil,
)
}
if request.Timeout < 0 {
return nil, e2bInvalidRequest("Exec", "execution timeout cannot be negative", nil)
}
// The SDK runs every command through `/bin/bash -l -c <cmd>`, so argv mode
// is lowered to a quoted shell line and stdin (when present) is piped in
// via a heredoc. This mirrors the Cube adapter exactly, keeping argv+stdin
// working identically across both remote backends.
cmd := request.Command
if !request.Shell {
cmd = buildShellLine(request.Command, request.Args)
}
if request.Stdin != "" {
cmd = wrapWithStdin(cmd, request.Stdin)
}
execCtx := ctx
cancel := context.CancelFunc(func() {})
if request.Timeout > 0 {
execCtx, cancel = context.WithTimeout(ctx, request.Timeout)
}
defer cancel()
start := time.Now()
options := []e2b.RunOption{}
if request.WorkDir != "" {
options = append(options, e2b.WithCwd(request.WorkDir))
}
if len(request.Env) > 0 {
options = append(options, e2b.WithEnv(cloneMetadata(request.Env)))
}
if request.User != "" {
options = append(options, e2b.WithUser(request.User))
}
if request.Timeout > 0 {
options = append(options, e2b.WithTimeout(request.Timeout))
}
result, err := sandbox.Commands.Run(execCtx, cmd, options...)
duration := time.Since(start)
if err != nil {
// A timeout is a normal RemoteExecResult with Killed=true, never a
// hard error. Detection cannot rely on execCtx.Err() alone: the SDK
// derives its own timeout context (from request.Timeout, or from its
// DefaultCommandTimeout when we pass none), and when *that* timer
// fires it cancels the SDK's child context rather than our execCtx.
// The SDK then surfaces every deadline as *e2b.TimeoutError, so we
// key off that plus our own deadline to cover whichever timer wins.
if isE2BExecTimeout(execCtx, err) {
return &RemoteExecResult{
Duration: duration,
Killed: true,
ExitCode: -1,
}, nil
}
// A non-zero exit code is reported by the SDK as *CommandExitError
// with the captured output still attached. This is a normal
// execution signal (a failing script, `grep` with no match,
// `exit 1`), not a wire failure, so lower it to a RemoteExecResult
// exactly like the Cube adapter — whose SDK returns the exit code
// inline. Only genuine transport/RPC errors fall through to
// normalizeE2BError below.
var exitErr *e2b.CommandExitError
if errors.As(err, &exitErr) {
return &RemoteExecResult{
Stdout: exitErr.Stdout,
Stderr: exitErr.Stderr,
ExitCode: exitErr.ExitCode,
Duration: duration,
}, nil
}
return nil, normalizeE2BError("Exec", err)
}
if result == nil {
return nil, NewRemoteError(
SandboxTypeE2B, "Exec", RemoteErrorKindInternal,
"e2b returned an empty command result", nil,
)
}
return &RemoteExecResult{
Stdout: result.Stdout,
Stderr: result.Stderr,
ExitCode: result.ExitCode,
Duration: duration,
}, nil
}
// Filesystem operations name DefaultSandboxExecUser explicitly rather than
// relying on the daemon's default account. It keeps ownership aligned with the
// account scripts run as, and it is required for interoperability: E2B Cloud
// falls back to "user" when the request omits it, while other E2B-compatible
// control planes reject the call outright.
func (c *E2BRemoteClient) WriteFile(
ctx context.Context,
handle RemoteSandboxHandle,
path string,
content []byte,
) error {
sandbox, err := e2bHandleSandbox("WriteFile", handle)
if err != nil {
return err
}
if strings.TrimSpace(path) == "" {
return e2bInvalidRequest("WriteFile", "path is required", nil)
}
if _, err := sandbox.Filesystem.WriteBytes(ctx, path, content, e2b.WithFileUser(DefaultSandboxExecUser)); err != nil {
return normalizeE2BError("WriteFile", err)
}
return nil
}
func (c *E2BRemoteClient) ReadFile(
ctx context.Context,
handle RemoteSandboxHandle,
path string,
) ([]byte, error) {
sandbox, err := e2bHandleSandbox("ReadFile", handle)
if err != nil {
return nil, err
}
if strings.TrimSpace(path) == "" {
return nil, e2bInvalidRequest("ReadFile", "path is required", nil)
}
content, err := sandbox.Filesystem.ReadBytes(ctx, path, e2b.WithFileUser(DefaultSandboxExecUser))
if err != nil {
return nil, normalizeE2BError("ReadFile", err)
}
return content, nil
}
func (c *E2BRemoteClient) ListDir(
ctx context.Context,
handle RemoteSandboxHandle,
path string,
) ([]RemoteDirEntry, error) {
sandbox, err := e2bHandleSandbox("ListDir", handle)
if err != nil {
return nil, err
}
if strings.TrimSpace(path) == "" {
return nil, e2bInvalidRequest("ListDir", "path is required", nil)
}
entries, err := sandbox.Filesystem.List(ctx, path, e2b.WithFileUser(DefaultSandboxExecUser))
if err != nil {
return nil, normalizeE2BError("ListDir", err)
}
result := make([]RemoteDirEntry, 0, len(entries))
for _, e := range entries {
result = append(result, RemoteDirEntry{
Name: e.Name,
Path: e.Path,
Type: e2bRemoteEntryType(e.Type),
Size: e.Size,
ModTime: e.ModTime,
})
}
return result, nil
}
func (c *E2BRemoteClient) MakeDir(
ctx context.Context,
handle RemoteSandboxHandle,
path string,
) error {
sandbox, err := e2bHandleSandbox("MakeDir", handle)
if err != nil {
return err
}
if strings.TrimSpace(path) == "" {
return e2bInvalidRequest("MakeDir", "path is required", nil)
}
if err := sandbox.Filesystem.MakeDir(ctx, path, e2b.WithFileUser(DefaultSandboxExecUser)); err != nil {
return ignoreExistingDir(normalizeE2BError("MakeDir", err))
}
return nil
}
func (c *E2BRemoteClient) Remove(
ctx context.Context,
handle RemoteSandboxHandle,
path string,
) error {
sandbox, err := e2bHandleSandbox("Remove", handle)
if err != nil {
return err
}
if strings.TrimSpace(path) == "" {
return e2bInvalidRequest("Remove", "path is required", nil)
}
if err := sandbox.Filesystem.Remove(ctx, path, e2b.WithFileUser(DefaultSandboxExecUser)); err != nil {
return normalizeE2BError("Remove", err)
}
return nil
}
func (c *E2BRemoteClient) Stat(
ctx context.Context,
handle RemoteSandboxHandle,
path string,
) (*RemoteStatEntry, error) {
sandbox, err := e2bHandleSandbox("Stat", handle)
if err != nil {
return nil, err
}
if strings.TrimSpace(path) == "" {
return nil, e2bInvalidRequest("Stat", "path is required", nil)
}
info, err := sandbox.Filesystem.Stat(ctx, path, e2b.WithFileUser(DefaultSandboxExecUser))
if err != nil {
return nil, normalizeE2BError("Stat", err)
}
if info == nil {
return nil, NewRemoteError(
SandboxTypeE2B, "Stat", RemoteErrorKindNotFound,
"path not found", nil,
)
}
return &RemoteStatEntry{
Path: info.Path,
Type: e2bRemoteEntryType(info.Type),
Size: info.Size,
ModTime: info.ModTime,
}, nil
}
// CreateSnapshot snapshots a running sandbox. E2B exposes snapshots on the
// connected sandbox handle, so the adapter reattaches first like Delete does.
func (c *E2BRemoteClient) CreateSnapshot(
ctx context.Context, sandboxID string, name string,
) (RemoteSnapshotRef, error) {
if strings.TrimSpace(sandboxID) == "" {
return RemoteSnapshotRef{}, e2bInvalidRequest("CreateSnapshot", "sandbox ID is required", nil)
}
timeoutSeconds, err := e2bTimeoutSeconds(
RemoteTimeoutPolicy{Mode: RemoteTimeoutServerDefault},
c.timeout,
)
if err != nil {
return RemoteSnapshotRef{}, e2bInvalidRequest("CreateSnapshot", err.Error(), err)
}
sandbox, err := c.client.Connect(ctx, sandboxID, timeoutSeconds)
if err != nil {
return RemoteSnapshotRef{}, normalizeE2BError("CreateSnapshot", err)
}
if sandbox == nil || strings.TrimSpace(sandbox.ID) == "" ||
sandbox.ID != sandboxID {
return RemoteSnapshotRef{}, NewRemoteError(
SandboxTypeE2B, "CreateSnapshot", RemoteErrorKindInternal,
"e2b returned a mismatched sandbox handle", nil,
)
}
var info *e2b.SnapshotInfo
if trimmed := strings.TrimSpace(name); trimmed != "" {
info, err = sandbox.CreateSnapshot(ctx, trimmed)
} else {
info, err = sandbox.CreateSnapshot(ctx)
}
if err != nil {
return RemoteSnapshotRef{}, normalizeE2BError("CreateSnapshot", err)
}
if info == nil || strings.TrimSpace(info.SnapshotID) == "" {
return RemoteSnapshotRef{}, e2bInvalidRequest(
"CreateSnapshot", "provider returned an empty snapshot ID", nil)
}
return RemoteSnapshotRef{ID: info.SnapshotID, Names: info.Names}, nil
}
// DeleteSnapshot removes a snapshot. go-e2b reports a missing snapshot as
// (false, nil), so cleanup stays idempotent while genuine provider errors
// still surface through the neutral error taxonomy.
func (c *E2BRemoteClient) DeleteSnapshot(ctx context.Context, snapshotID string) error {
if strings.TrimSpace(snapshotID) == "" {
return e2bInvalidRequest("DeleteSnapshot", "snapshot ID is required", nil)
}
if _, err := c.client.DeleteSnapshot(ctx, snapshotID); err != nil {
normalized := normalizeE2BError("DeleteSnapshot", err)
if IsRemoteNotFound(normalized) {
return nil
}
return normalized
}
return nil
}
// ListSnapshots pages through every snapshot, optionally filtered by source
// sandbox. Used only by the skill-image orphan-reconciliation task.
func (c *E2BRemoteClient) ListSnapshots(
ctx context.Context, sandboxID string,
) ([]RemoteSnapshotRef, error) {
baseOptions := []e2b.ListSnapshotsOption{e2b.WithSnapshotLimit(100)}
if trimmed := strings.TrimSpace(sandboxID); trimmed != "" {
baseOptions = append(baseOptions, e2b.WithSnapshotSandboxID(trimmed))
}
var (
out []RemoteSnapshotRef
token string
seen = map[string]struct{}{"": {}}
)
for {
options := append([]e2b.ListSnapshotsOption(nil), baseOptions...)
if token != "" {
options = append(options, e2b.WithSnapshotNextToken(token))
}
page, err := c.client.ListSnapshots(ctx, options...)
if err != nil {
return nil, normalizeE2BError("ListSnapshots", err)
}
if page == nil {
return nil, NewRemoteError(
SandboxTypeE2B,
"ListSnapshots",
RemoteErrorKindInternal,
"e2b returned an empty snapshot list page",
nil,
)
}
for _, item := range page.Snapshots {
out = append(out, RemoteSnapshotRef{ID: item.SnapshotID, Names: item.Names})
}
if page.NextToken == "" {
return out, nil
}
// A provider/SDK bug that repeats a pagination token (the same one,
// or a cycle A→B→A) would otherwise spin forever and block skill-
// image orphan cleanup. Fail fast instead of hanging until cancel.
if _, dup := seen[page.NextToken]; dup {
return nil, e2bInvalidRequest("ListSnapshots",
"provider returned a repeated pagination token", nil)
}
seen[page.NextToken] = struct{}{}
token = page.NextToken
}
}
// --- helpers -----------------------------------------------------------------
// e2bTimeoutSeconds maps the neutral RemoteTimeoutPolicy onto E2B's integer
// timeout. E2B only supports a positive TTL; "never" is rejected as
// unsupported so the lifecycle coordinator can pick a different policy.
func e2bTimeoutSeconds(policy RemoteTimeoutPolicy, fallback time.Duration) (int, error) {
toSeconds := func(value time.Duration) (int, error) {
if value > 0 && value < time.Second {
return 0, errors.New("E2B timeout must be at least one second")
}
seconds := value / time.Second
if value%time.Second != 0 {
seconds++
}
return int(seconds), nil
}
switch policy.Mode {
case "", RemoteTimeoutServerDefault:
if fallback <= 0 {
return 0, nil
}
return toSeconds(fallback)
case RemoteTimeoutExplicit:
if policy.Value < 0 {
return 0, errors.New("E2B backend does not support NeverTimeout")
}
return toSeconds(policy.Value)
default:
return 0, fmt.Errorf("unsupported timeout mode %q", policy.Mode)
}
}
// toE2BVolumeMounts converts the provider-neutral RemoteVolumeMount slice to
// E2B SDK VolumeMount values. Nil and empty inputs both produce nil.
func toE2BVolumeMounts(src []RemoteVolumeMount) []e2b.VolumeMount {
if len(src) == 0 {
return nil
}
result := make([]e2b.VolumeMount, len(src))
for i, mount := range src {
result[i] = e2b.VolumeMount{
Name: mount.Name,
Path: mount.Path,
}
}
return result
}
// toE2BRequestRules maps the neutral host rules onto E2B's per-host transform
// map. E2B has no notion of a deny verdict or an audit level here — a rule is
// only ever a header injection — which is why the neutral type carries the
// richer Cube shape separately instead of one merged rule type.
func toE2BRequestRules(rules []RemoteE2BHostRule) map[string][]e2b.RequestRule {
if len(rules) == 0 {
return nil
}
out := make(map[string][]e2b.RequestRule, len(rules))
for _, rule := range rules {
if len(rule.Headers) == 0 {
continue
}
headers := make(map[string]string, len(rule.Headers))
for name, value := range rule.Headers {
headers[name] = value
}
out[rule.Host] = append(out[rule.Host], e2b.RequestRule{
Transform: e2b.RequestTransform{Headers: headers},
})
}
return out
}
// isE2BExecTimeout reports whether a failed command Run should be treated as
// a timeout kill (Killed=true) rather than a transport error. It recognises:
// - our own execCtx deadline (request.Timeout);
// - a raw context deadline returned before the stream was established;
// - *e2b.TimeoutError, which the SDK returns for both its internal command
// timeout and any Connect deadline surfaced from the envd stream.
func isE2BExecTimeout(execCtx context.Context, err error) bool {
if errors.Is(execCtx.Err(), context.DeadlineExceeded) {
return true
}
if errors.Is(err, context.DeadlineExceeded) {
return true
}
var timeoutErr *e2b.TimeoutError
return errors.As(err, &timeoutErr)
}
func e2bHandleSandbox(op string, handle RemoteSandboxHandle) (*e2b.Sandbox, error) {
h, ok := handle.(*e2bRemoteHandle)
if !ok || h == nil || h.sandbox == nil {
return nil, e2bInvalidRequest(op, "handle was not issued by E2B", nil)
}
return h.sandbox, nil
}
func e2bRemoteSummary(info e2b.SandboxInfo) RemoteSandboxSummary {
startedAt, _ := time.Parse(time.RFC3339, info.StartedAt)
endAt, _ := time.Parse(time.RFC3339, info.EndAt)
return RemoteSandboxSummary{
ID: info.ID,
TemplateID: info.Template,
State: normalizeE2BState(info.State),
RawState: info.State,
Metadata: cloneMetadata(info.Metadata),
StartedAt: startedAt,
EndAt: endAt,
}
}
func normalizeE2BState(state string) RemoteSandboxState {
switch strings.ToLower(strings.TrimSpace(state)) {
case "running":
return RemoteStateRunning
case "paused":
return RemoteStatePaused
case "pending", "creating", "provisioning", "starting", "pausing", "resuming":
return RemoteStateTransitioning
case "killed", "stopped", "terminated", "deleted", "failed", "error":
return RemoteStateTerminal
default:
return RemoteStateUnknown
}
}
func e2bInvalidRequest(op, message string, cause error) error {
return NewRemoteError(
SandboxTypeE2B, op, RemoteErrorKindInvalidRequest, message, cause,
)
}
func normalizeE2BError(op string, err error) error {
if err == nil {
return nil
}
if errors.Is(err, context.Canceled) {
return fmt.Errorf("e2b %s: %w", op, err)
}
kind := RemoteErrorKindInternal
status := 0
switch {
case errors.Is(err, context.DeadlineExceeded):
kind = RemoteErrorKindTimeout
default:
var sandboxNotFound *e2b.SandboxNotFoundError
var fileNotFound *e2b.FileNotFoundError
var templateNotFound *e2b.TemplateNotFoundError
var invalidArg *e2b.InvalidArgumentError
var authErr *e2b.AuthenticationError
var apiErr *e2b.Error
var timeoutErr *e2b.TimeoutError
var connectErr *connect.Error
var networkErr net.Error
switch {
case errors.As(err, &sandboxNotFound):
kind = RemoteErrorKindNotFound
case errors.As(err, &fileNotFound):
kind = RemoteErrorKindNotFound
case errors.As(err, &templateNotFound):
kind = RemoteErrorKindInvalidRequest
// The envd process/filesystem RPC layer converts Connect codes into
// these typed errors (see the SDK's mapProcessRPCError), so they
// never reach the *connect.Error branch below. Match them explicitly
// or they would fall through to the default RemoteErrorKindInternal.
case errors.As(err, &invalidArg):
kind = RemoteErrorKindInvalidRequest
case errors.As(err, &authErr):
kind = RemoteErrorKindAuthentication
case errors.As(err, &timeoutErr):
kind = RemoteErrorKindTimeout
case errors.As(err, &apiErr):
kind = httpErrorKind(op, apiErr.StatusCode)
status = apiErr.StatusCode
case errors.As(err, &connectErr):
switch connectErr.Code() {
case connect.CodeCanceled, connect.CodeDeadlineExceeded:
kind = RemoteErrorKindTimeout
case connect.CodeUnavailable:
kind = RemoteErrorKindUnavailable
case connect.CodeUnauthenticated, connect.CodePermissionDenied:
kind = RemoteErrorKindAuthentication
case connect.CodeResourceExhausted:
kind = RemoteErrorKindCapacity
case connect.CodeInvalidArgument, connect.CodeOutOfRange:
kind = RemoteErrorKindInvalidRequest
case connect.CodeNotFound:
kind = RemoteErrorKindNotFound
case connect.CodeAlreadyExists, connect.CodeAborted, connect.CodeFailedPrecondition:
kind = RemoteErrorKindConflict
case connect.CodeUnimplemented:
kind = RemoteErrorKindUnsupported
case connect.CodeInternal, connect.CodeDataLoss, connect.CodeUnknown:
kind = RemoteErrorKindInternal
}
case errors.As(err, &networkErr):
if networkErr.Timeout() {
kind = RemoteErrorKindTimeout
} else {
kind = RemoteErrorKindUnavailable
}
}
}
kind = snapshotDeleteKind(op, kind, err.Error())
remoteErr := NewRemoteError(SandboxTypeE2B, op, kind, err.Error(), err)
remoteErr.StatusCode = status
return remoteErr
}
// e2bRemoteEntryType maps the E2B SDK FileInfo.Type onto the provider-neutral
// RemoteDirEntryType.
func e2bRemoteEntryType(fileType string) RemoteDirEntryType {
switch strings.ToLower(strings.TrimSpace(fileType)) {
case "file":
return RemoteEntryFile
case "directory", "dir":
return RemoteEntryDir
default:
return RemoteEntryOther
}
}
var (
_ RemoteSandboxClient = (*E2BRemoteClient)(nil)
_ RemoteSnapshotManager = (*E2BRemoteClient)(nil)
_ RemoteTemplateCatalog = (*E2BRemoteClient)(nil)
_ RemoteSandboxHandle = (*e2bRemoteHandle)(nil)
_ RemoteInboundTokenCarrier = (*e2bRemoteHandle)(nil)
)