From 941210b448bacdee832ace2c8643ee4812f93f40 Mon Sep 17 00:00:00 2001 From: CMGS Date: Mon, 28 Sep 2026 17:22:02 +0800 Subject: [PATCH 1/4] fix(e2bcompat): a create may name a built template by one of its tags Template.build stores app:v1 as app with the tag v1, but a create handed the whole app:v1 to resolveTemplate, which indexes bare names, so every tagged reference answered 404 and only app worked; name:tag now resolves to the template when the tag is one of its live tags, name:default to the current build. --- docs/e2b-compat.md | 2 +- pkg/e2bcompat/templates.go | 13 +++++++++---- pkg/e2bcompat/templates_test.go | 19 +++++++++++++++++++ 3 files changed, 29 insertions(+), 5 deletions(-) diff --git a/docs/e2b-compat.md b/docs/e2b-compat.md index b878b53..3685dc1 100644 --- a/docs/e2b-compat.md +++ b/docs/e2b-compat.md @@ -145,7 +145,7 @@ and its first `runCode` fails with `502`. | e2b endpoint | Maps to | Notes | |---|---|---| -| `POST /sandboxes`, `POST /v2/sandboxes` | `store.Claim` | `templateID` → pool template, through `--e2b-template-alias-file` when it names an alias; `timeout` → the claim's TTL (`--e2b-default-timeout` when omitted); `allow_internet_access: true` → `egress` lane, anything else the hardened `none` lane; `metadata` → the claim's metadata; `autoPause: true` → the claim is paused (hibernated and archived) at lease end instead of destroyed, and connect resumes it. Both ride on the same claim call and need sandboxd built from 3fd7af2 or later. `envVars` becomes `envd`'s default environment through the `/init` that follows the claim (see the access token below); a failed `/init` releases the claim and answers `500`. `201` on success, `400` for an option this backend cannot honor (see below), `404` at once for a `templateID` spelled as a full `e2b/` key (a built template is named bare and scoped by the key's namespace), `503` when the pool is drained (retryable), `404` when no warm capacity answers and the `templateID` names nothing known — no alias, no [built template](#built-templates) of the namespace, no advertised pool image. The check runs only on that failure branch. SDK 2.51 creates through `/v2`. | +| `POST /sandboxes`, `POST /v2/sandboxes` | `store.Claim` | `templateID` → pool template, through `--e2b-template-alias-file` when it names an alias, or a [built template](#built-templates) by name, `name:tag` for one of its live tags or `name:default` for the current build; `timeout` → the claim's TTL (`--e2b-default-timeout` when omitted); `allow_internet_access: true` → `egress` lane, anything else the hardened `none` lane; `metadata` → the claim's metadata; `autoPause: true` → the claim is paused (hibernated and archived) at lease end instead of destroyed, and connect resumes it. Both ride on the same claim call and need sandboxd built from 3fd7af2 or later. `envVars` becomes `envd`'s default environment through the `/init` that follows the claim (see the access token below); a failed `/init` releases the claim and answers `500`. `201` on success, `400` for an option this backend cannot honor (see below), `404` at once for a `templateID` spelled as a full `e2b/` key (a built template is named bare and scoped by the key's namespace), `503` when the pool is drained (retryable), `404` when no warm capacity answers and the `templateID` names nothing known — no alias, no [built template](#built-templates) of the namespace, no advertised pool image. The check runs only on that failure branch. SDK 2.51 creates through `/v2`. | | `GET /sandboxes`, `GET /v2/sandboxes` | `store.List` | Live sandboxes in the key's namespace. The `state` (`running`, `paused`), `template` and `startedAfter` (at the second precision `startedAt` carries) filters are honored, and so is `metadata`: `key=value` pairs joined by `&`, each key and value URL-encoded as the JS and Python SDKs send `query.metadata`; every pair must match; a pair without `=`, an empty key or a repeated key is `400`. Each item carries its `metadata`, `cpuCount` and `memoryMB`. `/v2` pages as the spec says: `limit` (1 to 100, default 100), `order` by start time (`desc`, newest first, by default, or `asc`) and `nextToken`, the opaque cursor the previous page returned in `X-Next-Token`. The cursor names the last sandbox served, so a sandbox created or released between pages neither repeats nor skips the rest. An out-of-range `limit`, an unknown `order` or a malformed `nextToken` is `400`. The legacy `GET /sandboxes` takes no page parameters and returns every match. | | `GET /sandboxes/{id}` | `store.GetByClaimID`, then `store.Read` | Resolves the owning node and materializes only that entry; `state` and `endAt` come from the node's own record, so they reflect a pause, resume or renew at once; `404` when no live sandbox carries the id. | | `DELETE /sandboxes/{id}` | `store.Release` | Releases the node-local claim id, never by Kubernetes name. `204`, also when the owning node already reaped it: release is idempotent. `404` when the read view no longer lists the id. | diff --git a/pkg/e2bcompat/templates.go b/pkg/e2bcompat/templates.go index 2b2c1f9..7fdf30d 100644 --- a/pkg/e2bcompat/templates.go +++ b/pkg/e2bcompat/templates.go @@ -288,14 +288,19 @@ func (s *Server) builtTemplate(w http.ResponseWriter, r *http.Request, name stri return nil, false } -// resolveTemplate reports name's built template in the caller's namespace, and whether an alias or an advertised pool image names it. -func (s *Server) resolveTemplate(r *http.Request, name string) (*builtTemplate, bool, error) { - _, aliased := s.aliases[name] +// resolveTemplate reports ref's built template in the caller's namespace (ref is a name, or name:tag for one of its live tags), and whether an alias or an advertised pool image names ref. +func (s *Server) resolveTemplate(r *http.Request, ref string) (*builtTemplate, bool, error) { + _, aliased := s.aliases[ref] nodes, err := s.inventories(r.Context()) if err != nil { return nil, aliased, err } - return builtTemplates(nodes, s.templateScope(r), name)[name], aliased || advertisedIn(nodes, name), nil + name, tag, _ := strings.Cut(ref, ":") + b := builtTemplates(nodes, s.templateScope(r), name)[name] + if b != nil && tag != "" && tag != defaultTag && b.liveOnly(b.current().labels)[tag] == "" { + b = nil + } + return b, aliased || advertisedIn(nodes, ref), nil } func (s *Server) templateScope(r *http.Request) string { diff --git a/pkg/e2bcompat/templates_test.go b/pkg/e2bcompat/templates_test.go index d4f5332..80eef17 100644 --- a/pkg/e2bcompat/templates_test.go +++ b/pkg/e2bcompat/templates_test.go @@ -153,6 +153,25 @@ func TestACreateNamingABuiltTemplateClaimsItsKey(t *testing.T) { assert.Equal(t, 1, store.claimCalls, "a drained alias pool never falls through to a built template of the same name") } +func TestACreateNamingATagOfABuiltTemplateClaimsItsKey(t *testing.T) { + created := &metav1.Time{Time: time.Date(2026, 9, 28, 1, 2, 3, 0, time.UTC)} + for ref, want := range map[string]int{"app": http.StatusCreated, "app:v1": http.StatusCreated, "app:default": http.StatusCreated, "app:v9": http.StatusNotFound, "app:old": http.StatusNotFound} { + inv := scale.NewStaticInventorySource() + inv.Put(&scale.NodeInventory{Node: "node-a", Templates: []scale.PromotedTemplate{{ + Template: "e2b/sandboxes/app", Net: "none", Size: "small", ContentDigest: "sha256:aa", CreatedAt: created, + Labels: map[string]string{"v1": "sha256:aa", "old": "sha256:gone"}, + }}}) + store := &fakeStore{firstClaimErr: scale.ErrNoWarmCapacity, assign: scale.Assignment{SandboxName: "sb_1", Node: "node-a"}, fleet: inv} + h := newTestServer(t, store, func(o *Options) { o.Inventory = inv }) + + w := do(t, h, http.MethodPost, "/sandboxes", `{"templateID":"`+ref+`"}`, testKey) + assert.Equal(t, want, w.Code, "%s: %s", ref, w.Body.String()) + if want == http.StatusCreated { + assert.Equal(t, "e2b/sandboxes/app", store.claimPool.Template, ref) + } + } +} + func TestABuiltTemplateCreateKeepsTheTemplateDefaultsAndLayersTheRequestEnvs(t *testing.T) { store := &fakeStore{firstClaimErr: scale.ErrNoWarmCapacity, assign: scale.Assignment{SandboxName: "sb_1", Node: "node-a", Token: "tok"}} h := newTestServer(t, store, withTemplateFleet(store)) From 44f740bbf7c09a76f2299ea3ac746e8714a3b299 Mon Sep 17 00:00:00 2001 From: CMGS Date: Mon, 28 Sep 2026 17:22:02 +0800 Subject: [PATCH 2/4] fix(envdproxysmoke): the harness passes the node token NewServer requires Since 40002c83 a signed file URL relays with the node token and NewServer refuses to start without one; the harness never set it and exited at once. --- docs/envd-proxy.md | 2 +- docs/performance.md | 2 +- test/envdproxysmoke/main.go | 11 ++++++----- 3 files changed, 8 insertions(+), 7 deletions(-) diff --git a/docs/envd-proxy.md b/docs/envd-proxy.md index 71cd762..91902fd 100644 --- a/docs/envd-proxy.md +++ b/docs/envd-proxy.md @@ -159,7 +159,7 @@ portsmoke -addr 127.0.0.1:7990 -token -template \ -listener ./guestserver -hold 300s # prints: SANDBOX go run -tags envdproxysmoke ./test/envdproxysmoke \ - -node -sandbox -token -port 49983 + -node -sandbox -token -node-token -port 49983 ``` `-guest envd` swaps the assertions for the real daemon (health, a ConnectRPC diff --git a/docs/performance.md b/docs/performance.md index 01d6fc2..8fe579a 100644 --- a/docs/performance.md +++ b/docs/performance.md @@ -235,7 +235,7 @@ go test -run '^$' -bench . ./pkg/scale ./pkg/e2bcompat # envd-proxy against a live sandbox (see envd-proxy.md for the node half) go run -tags envdproxysmoke ./test/envdproxysmoke \ - -node -sandbox -token -port 49983 + -node -sandbox -token -node-token -port 49983 # envd in a real guest (see e2b-compat.md, Proving envd on hardware) K= TEMPLATE=ghcr.io/cocoonstack/sandbox/e2b-rt:24.04 bash scripts/envd-e2e.sh diff --git a/test/envdproxysmoke/main.go b/test/envdproxysmoke/main.go index 2e41170..36f3286 100644 --- a/test/envdproxysmoke/main.go +++ b/test/envdproxysmoke/main.go @@ -123,23 +123,24 @@ func main() { node := flag.String("node", "", "owning node's sandboxd address") sandboxID := flag.String("sandbox", "", "node-local claim id") token := flag.String("token", "", "per-sandbox access token") + nodeToken := flag.String("node-token", "", "node api token; a signed file URL relays with it") port := flag.Uint("port", 49983, "guest port the listener is on") guestHTTP2 := flag.Bool("guest-http2", false, "forward to the guest over cleartext HTTP/2") mode := flag.String("guest", "echo", "what listens in the guest: echo (guestserver) or envd") flag.Parse() - if *node == "" || *sandboxID == "" || *token == "" { - fmt.Fprintln(os.Stderr, "envdproxysmoke: -node, -sandbox and -token are required") + if *node == "" || *sandboxID == "" || *token == "" || *nodeToken == "" { + fmt.Fprintln(os.Stderr, "envdproxysmoke: -node, -sandbox, -token and -node-token are required") os.Exit(1) } - if err := run(*node, *sandboxID, *token, uint16(*port), *guestHTTP2, *mode); err != nil { + if err := run(*node, *sandboxID, *token, *nodeToken, uint16(*port), *guestHTTP2, *mode); err != nil { fmt.Fprintln(os.Stderr, "envdproxysmoke:", err) os.Exit(1) } fmt.Println("ENVDPROXYSMOKE PASS") } -func run(node, sandboxID, token string, port uint16, guestHTTP2 bool, mode string) error { +func run(node, sandboxID, token, nodeToken string, port uint16, guestHTTP2 bool, mode string) error { ctx, cancel := context.WithTimeout(context.Background(), 120*time.Second) defer cancel() @@ -148,7 +149,7 @@ func run(node, sandboxID, token string, port uint16, guestHTTP2 bool, mode strin publicID := e2bcompat.PublicID(sandboxID) access := e2bcompat.AccessToken([]byte("envdproxysmoke"), token) srv, err := envdproxy.NewServer(staticResolver{claimID: sandboxID, address: node, publicID: publicID, token: token, access: access}, - envdproxy.Options{Domain: domain, GuestHTTP2: guestHTTP2}) + envdproxy.Options{Domain: domain, GuestHTTP2: guestHTTP2, NodeToken: nodeToken}) if err != nil { return err } From 4f15cb800487c0e335f7b8311b7afd9b94e7c755 Mon Sep 17 00:00:00 2001 From: CMGS Date: Mon, 28 Sep 2026 17:22:02 +0800 Subject: [PATCH 3/4] fix(helm): the envd proxy's chart requires the sandboxd token secret The proxy exits at startup without --sandboxd-token-file, and the chart passed it only when apiserver.sandboxdToken.secretName was set; the render now fails with the reason instead. --- helm/templates/envdproxy-deployment.yaml | 9 ++------- 1 file changed, 2 insertions(+), 7 deletions(-) diff --git a/helm/templates/envdproxy-deployment.yaml b/helm/templates/envdproxy-deployment.yaml index def3fc9..1e9062f 100644 --- a/helm/templates/envdproxy-deployment.yaml +++ b/helm/templates/envdproxy-deployment.yaml @@ -1,5 +1,6 @@ {{- if .Values.apiserver.e2b.enabled }} {{- $tlsSecret := .Values.envdProxy.tlsSecretName -}} +{{- $tokenSecret := required "apiserver.sandboxdToken.secretName is required when apiserver.e2b.enabled is true: the envd proxy reads claim tokens with it" .Values.apiserver.sandboxdToken.secretName -}} apiVersion: apps/v1 kind: Deployment metadata: @@ -39,9 +40,7 @@ spec: - --domain={{ . }} {{- end }} - --e2b-envd-secret-file=/etc/sandbox-envd-proxy/e2b-envd-secret/{{ .Values.apiserver.e2b.envdSecret.key }} - {{- if .Values.apiserver.sandboxdToken.secretName }} - --sandboxd-token-file=/etc/sandbox-envd-proxy/sandboxd-token/{{ .Values.apiserver.sandboxdToken.key }} - {{- end }} {{- if $tlsSecret }} - --tls-cert-file=/etc/sandbox-envd-proxy/serving-certs/tls.crt - --tls-private-key-file=/etc/sandbox-envd-proxy/serving-certs/tls.key @@ -70,11 +69,9 @@ spec: - name: e2b-envd-secret mountPath: /etc/sandbox-envd-proxy/e2b-envd-secret readOnly: true - {{- if .Values.apiserver.sandboxdToken.secretName }} - name: sandboxd-token mountPath: /etc/sandbox-envd-proxy/sandboxd-token readOnly: true - {{- end }} {{- if $tlsSecret }} - name: serving-certs mountPath: /etc/sandbox-envd-proxy/serving-certs @@ -84,11 +81,9 @@ spec: - name: e2b-envd-secret secret: secretName: {{ include "sandbox-operator.e2b.envdSecretName" . }} - {{- with .Values.apiserver.sandboxdToken.secretName }} - name: sandboxd-token secret: - secretName: {{ . }} - {{- end }} + secretName: {{ $tokenSecret }} {{- if $tlsSecret }} # A wildcard certificate for *.{domain}: the SDK addresses every sandbox # by its own derived host. From ce98cae87eb228a4c457a8516d980ecf27a3646f Mon Sep 17 00:00:00 2001 From: CMGS Date: Mon, 28 Sep 2026 17:22:02 +0800 Subject: [PATCH 4/4] review(envdproxy): the probe comment follows the code A refused admit answers 401 since #80; the comment still said not found. --- pkg/envdproxy/resolver.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/pkg/envdproxy/resolver.go b/pkg/envdproxy/resolver.go index e3d8969..e4b0093 100644 --- a/pkg/envdproxy/resolver.go +++ b/pkg/envdproxy/resolver.go @@ -119,8 +119,8 @@ func (s *storeResolver) find(ctx context.Context, sandboxID string, admit admiss return s.readOwner(ctx, sandboxID, sb.Status.NodeName, sb.Annotations[scale.ClaimIDAnnotation], admit) } -// probe asks every node for claimID, which its inventory does not list yet, and keeps a hit past its -// next publish; a refused admit reads as not found, so an unpublished id stays unprovable. +// probe asks every node for claimID, which its inventory does not list yet, keeps a hit past its +// next publish and admits the caller like a published one. func (s *storeResolver) probe(ctx context.Context, sandboxID, claimID string, admit admission) (Owner, error) { if !s.probeLimit.Allow() { return Owner{}, ErrSandboxNotFound