Thanks to visit codestin.com
Credit goes to github.com

Skip to content

environments: the E2B builder — build, inspect, exists (E2-03) - #29

Open
echarles wants to merge 3 commits into
mainfrom
feat/e2b-builder
Open

environments: the E2B builder — build, inspect, exists (E2-03)#29
echarles wants to merge 3 commits into
mainfrom
feat/e2b-builder

Conversation

@echarles

Copy link
Copy Markdown
Member

Summary

Implements the build half of PLAN_ENV.md section 11.2 (E2-03): a template from the resolved lock, with a real, live-verified build against E2B.

A deviation from the plan's own wording, decided and documented: the template starts from E2B's own code-interpreter-v1, not from the Datalayer ECR base via from_image. Section 11.2 asks for "a pinned code-interpreter server layer" on top of the Datalayer base — that server (E0-04: a FastAPI service on port 49999 and a token-less Jupyter Server on 8888) is E2B's own, proprietary, and not published anywhere this package could vendor it from. Starting from code-interpreter-v1 instead, then running uv pip sync --require-hashes against the resolved lock, reconciles whatever packages it shipped to exactly what the lock pins — the same reconciliation E1-07 already relies on when a base's own versions differ from a lock's, so nothing about which packages end up installed depends on which base the chain started from.

  • build(): copies the doctor (built fresh via doctor/build.py), the wheelhouse and the lock in; uv pip sync --require-hashes; files_step()'s baked files and postInstall run under the datalayer account; the contract's own doctor check at build time. The artifact is <team>/<name>:<build_id> (E0-04's own verified format — <name> alone launches the newest build, this pins one); the template id travels in ArtifactReference.mutable_alias so inspect/exists need no second lookup.
  • inspect()/exists(): read the template's tags for the one matching this build's id.
  • resolve()/delete()/smoke_test() still refuse via the inherited ManagedBuilder — smoke_test deliberately: nothing on the real build workflow calls it today (durable's own _SmokeTest seam is gated on E1-14 and has a different signature). See the module's own docstring for the full reasoning.

Live-verified, and what's still open

A real build ran against E2B with a throwaway template (deleted — well, not quite: see below), using real credentials in this environment. Three real findings along the way, each documented in the code where it matters:

  1. Template().copy()'s source must be relative to a context directory (Template(file_context_path=...)) — an absolute path is refused outright.
  2. code-interpreter-v1 sets its own persistent default user for every run_cmd; root-needing steps need an explicit user="root".
  3. set_user("datalayer") must come after a real useradd, never before one exists — the wrong order left the account unable to exec anything, /bin/sh: permission denied, with no further diagnostic.

What's not yet right, and why this doesn't tick E2-03 in plans/ENV.md: code-interpreter-v1 already holds an account at uid 1000 or gid 100 of its own, so the datalayer account this builder creates lands on uid 1001, gid 1001 — not the contract's 1000:100 — and the content directory (chowned to the numbers asked for, not the ones the account got) reads as not writable. datalayer-sandbox doctor --json correctly fails the build on both, so a build refuses today rather than shipping an artifact the contract's own check wouldn't accept. Fixing the numeric mismatch needs either finding what already holds those numbers in code-interpreter-v1, or E2B's own guidance — not attempted further after three live builds each spent confirming one hypothesis.

One live build did complete and produced a real build_id (a minimal spec, before the uid/gid check was gating); its template and account were not deleted — the E2B Python SDK exposes no template-delete call in this version, only tag removal. Named dl-e2b-live-drill-v1, easy to find and remove from the E2B dashboard if wanted.

Tests

pytest tests/ -k environment: 715 passed, 4 skipped (this branch is off main, before E3-05 landed on its own branch). ruff check and mypy clean on every file touched. 17 new tests (tests/test_environment_e2b_builder.py) with a Template/TemplateBuilder double; 2 existing tests in test_environment_managed_builders.py updated to reflect what E2B now actually implements.

🤖 Generated with Claude Code

Implements the build half of section 11.2: a template from the resolved
lock, starting from E2B's own code-interpreter-v1 rather than the Datalayer
ECR base — that base's own patched code-interpreter server (E0-04: the
FastAPI/Jupyter service on 49999/8888) is proprietary and not published
anywhere this package could vendor it from, so the chain starts from
E2B's own template instead and reconciles it to the lock the same way
E1-07 already reconciles a base's own package versions. adapters/e2b.py's
own module docstring has the full reasoning.

- build(): copies the doctor (built fresh via doctor/build.py), the
  wheelhouse and the lock in, uv pip sync --require-hashes on top of
  code-interpreter-v1's own packages, files_step()'s baked files and
  postInstall run under the datalayer account set_user creates, and the
  contract's own doctor check at build time. The artifact is
  <team>/<name>:<build_id> (E0-04's own verified format); the template id
  travels in ArtifactReference.mutable_alias for inspect/exists to use
  without a second lookup.
- inspect()/exists(): read the template's tags for the one matching this
  build's id.
- resolve()/delete()/smoke_test() still refuse via the inherited
  ManagedBuilder — smoke_test deliberately so, since nothing on the real
  build workflow calls it (durable's own _SmokeTest seam is gated on
  E1-14, with a different signature); see the module docstring.

Three real findings from live builds against a throwaway template
(code_sandboxes/environments/adapters/e2b.py's own comments carry the
full account): copy()'s source must be relative to a context directory;
code-interpreter-v1's persistent default user means root steps need an
explicit user="root"; and set_user("datalayer") must come after a real
useradd, never before one exists, or the account can't exec anything at
all. What remains, live-verified but not yet passing: code-interpreter-v1
already holds uid 1000 or gid 100 under another account, so datalayer
lands on 1001:1001 instead of the contract's 1000:100, and the doctor
correctly refuses the build on it — E2-03 stays unticked in plans/ENV.md
until that numeric mismatch is resolved.

Tests: 17 new (test_environment_e2b_builder.py) with a Template/
TemplateBuilder double, plus 2 existing tests updated for the operations
E2B now actually implements. 715 passed on the full environments suite,
ruff and mypy clean.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
Copilot AI lite review requested due to automatic review settings September 13, 2026 11:25

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

Unresolved critical build and credential-handling issues prevent safe approval.

Get a fresh assessment by requesting another Copilot review.

Pull request overview

Adds E2B managed-environment template building, inspection, and existence checks.

Changes:

  • Builds templates from code-interpreter-v1 using the resolved lock and doctor.
  • Adds build-id artifact references and tag-based lookup.
  • Adds E2B tests and updates managed-builder expectations.
File summaries
File Summary Review findings
tests/test_environment_managed_builders.py Updated managed-builder expectations. No findings.
tests/test_environment_e2b_builder.py Added E2B builder coverage. No findings.
code_sandboxes/environments/adapters/e2b.py Implements E2B build, inspection, and existence operations. Critical (3): uid/gid mismatch can prevent compliant artifacts. Critical (1): supplied credentials are not applied. Moderate (2): apt packages, build secrets, provider account binding, provider error mapping, and environment timing need correction. Moderate (1): empty locks and missing SDK handling need correction.
Review details

Suppressed comments (3)

code_sandboxes/environments/adapters/e2b.py:378

  • The same unhandled SDK failure exists in exists: only a false result is treated as absence, while get_tags can still raise a raw authentication or transport exception. Normalize provider failures to EnvironmentsError here as well so callers can distinguish a missing artifact from an unavailable E2B registry.
        if not template_cls.exists(template_id or name):
            return False
        tags = template_cls.get_tags(template_id or name)
        return any(getattr(tag, "build_id", None) == artifact.provider_artifact_id for tag in tags)

code_sandboxes/environments/adapters/e2b.py:208

  • Now that build() unconditionally syncs request.lock_text, capability validation must reject an empty lock before work is queued. _own_findings still ignores lock_text, so an empty resolved lock is reported as supported and only fails later after removing or omitting the protected kernel packages; mirror the Datalayer builder's locked_versions check.
    def build(self, request: BuildRequest) -> ArtifactReference:
        """Build a template from the resolved lock, and keep the build id.

        `code-interpreter-v1` is the starting point (see the module

code_sandboxes/environments/adapters/e2b.py:143

  • A missing optional e2b SDK is a local capability/dependency problem, not an unmapped provider failure. Using PROVIDER_ERROR here makes the error retryable (PROVIDER_ERROR is retryable in errors.py), unlike the Datalayer adapter's CAPABILITY_UNSUPPORTED handling for a missing SDK, so callers may retry indefinitely instead of installing the extra.
    except ImportError as error:  # pragma: no cover - exercised by the extra
        raise EnvironmentsError(
            PROVIDER_ERROR,
            "No `e2b` SDK to build a template with: install `code-sandboxes[e2b]`",
            detail={"missing": "e2b"},
        ) from error
  • Files reviewed: 3/3 changed files
  • Comments generated: 7
  • Review effort level: Lite

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment on lines +271 to +275
.run_cmd(
f"groupadd -g 100 {_CONTRACT_USER} 2>/dev/null; "
f"useradd -u 1000 -g 100 -m -d /home/{_CONTRACT_USER} -s /bin/bash "
f"{_CONTRACT_USER} 2>/dev/null; "
f"mkdir -p {_CONTENT_DIR} && chown -R 1000:100 /home/{_CONTRACT_USER}",

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Already the PR's own documented open gap — see the PR description's "What's still open" section and the module docstring's "What remains before E2-03 ticks" note. This is exactly why the box isn't ticked: a real build refuses today on precisely this (uid 1001/gid 1001, not the contract's 1000:100), rather than shipping an artifact the doctor wouldn't accept. Not attempted further after three live builds each spent confirming one hypothesis — the module docstring has the full account of what was tried.

@echarles echarles Sep 13, 2026

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed for real in e8e6b31: renaming whoever already holds uid 1000 (found live: user, on code-interpreter-v1) onto the contract's name/gid/home, rather than creating a second account — confirmed live to land exactly uid=1000(datalayer) gid=100(users). Falls back to a plain useradd if a future base has no such account. A real build now passes datalayer-sandbox doctor --json in full at build time.

That fix uncovered two more real bugs, also fixed in the same commit: the doctor's own locale check (code-interpreter-v1 sets none at all) and a collision with E2B's own post-build "configuration script" recreating a default user account, which failed once the renamed account's leftover private group was still there.

A fourth, deeper gap remains, root-caused but not fixed here (see the module docstring): a live launch still runs as root, because jupyter.service/code-interpreter.service have no User= and E2B's own private server hardcodes /home/user as the cwd, independent of the build's own set_user/set_workdir.

self._log(message)

try:
info = template_cls.build(chain, name, tags=[tag], on_build_logs=on_build_logs)

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 427de45: the credential's provider_secrets (D-8, E2-01) now supplies api_key= to Template.build/get_tags/exists, falling back to the SDK's own ambient E2B_API_KEY only when no credential is given.

Comment on lines +286 to +290
.run_cmd(
"uv pip sync --system --require-hashes "
f"--find-links {_WHEELHOUSE_PATH} {_LOCK_PATH}",
user="root",
)

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 427de45: apt_pins_in(request.lock_text) is now installed via apt-get, as root, before uv pip sync — the same pins the Datalayer builder installs from the same lock.

Comment on lines +292 to +293
if spec.env:
chain = chain.set_envs(dict(spec.env))

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 427de45: set_envs now runs right after set_user/set_workdir, before the apt install and uv pip sync steps — matching the Datalayer builder's own ordering and reasoning.

Comment on lines +294 to +297
for command in files_step(request.environment, variant=self.variant):
chain = chain.run_cmd(command)
for command in spec.commands.post_install:
chain = chain.run_cmd(command)

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 427de45: a spec naming buildSecrets is now refused in _own_findings, at validate() time, the same way a GPU class already is — E0-04's spike found no per-step secret mechanism for E2B, only a registry login for the private base.

Comment on lines +331 to +335
provider_artifact_id=info.build_id,
size_class=request.size_class,
# The template id, never launched from, kept so `inspect`,
# `exists` and `delete` need no extra round trip to resolve a
# name to an id.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 427de45: provider_account (E2-01) is now populated from accounts.provider_account(self.variant, self._provider_secrets()) on the returned ArtifactReference.

Comment on lines +349 to +351
template_cls = self._template_cls()
template_id, name = self._ids(artifact)
tags = template_cls.get_tags(template_id or name)

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 427de45: both get_tags and exists now go through a shared helper that maps any exception to DL_ENV_PROVIDER_ERROR, matching the Datalayer registry adapter's own provider-call discipline. Line 375 (exists' own get_tags call) shares the same fix.

…er, more

Six real findings from this PR's own review, all fixed:

- The owner's credential (D-8) is now actually passed to the E2B SDK —
  Template.build/get_tags/exists all take api_key= from
  BuildCredential.provider_secrets when one is given, falling back to the
  SDK's own ambient E2B_API_KEY otherwise. Without this, a multi-owner
  worker would have built in whichever team the worker process itself was
  configured for, never the environment owner's.
- spec.packages.system.apt's locked pins are now installed (apt-get, as
  root, before uv pip sync) — this chain used to run uv pip sync alone and
  silently ship a spec's system dependencies missing.
- set_envs moved before any install step, matching the Datalayer builder's
  own reasoning (E1-07): a package that compiles against a library found
  through an env var behaves differently without it.
- A spec naming buildSecrets is now refused in _own_findings, the same
  way this variant already refuses a GPU class — E0-04's spike found no
  per-step secret mechanism for E2B, only a registry login.
- ArtifactReference.provider_account is now populated from the credential
  (E2-01) — previously always unset, so a launch could never verify the
  account an artifact was built in.
- get_tags/exists failures now map to DL_ENV_PROVIDER_ERROR instead of
  escaping as whatever the SDK happens to raise, matching the Datalayer
  registry adapter's own provider-call discipline.

The uid:gid gap (Copilot's first comment) is the one already documented in
this PR's own description and the module's "what remains" note — not new,
and not something this pass attempts again.

Tests: 11 new (env ordering, apt install, credential threading, provider
account, error mapping, build-secret refusal), 28 in this file total, 726
on the full environments suite. ruff, mypy and ruff-format clean.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
…deeper runtime one

Three live-confirmed bugs, fixed:

- The account created for the contract never actually landed on uid 1000,
  gid 100: code-interpreter-v1 already holds an account at uid 1000 (`user`)
  and a group at gid 100 (`users`) of its own, so `useradd -u 1000 -g 100`
  silently fell back to the next free numbers instead of erroring. Fixed by
  renaming whoever already holds uid 1000 (found live, via a build-free
  probe of code-interpreter-v1) rather than creating a second account,
  falling back to a plain useradd if a future base has no such account.

- The doctor's own locale check failed: code-interpreter-v1 sets no locale
  at all, unlike the Datalayer base (E1-05) Daytona and Modal both inherit
  it from. Fixed by setting LC_ALL/LANG=C.UTF-8 unconditionally, merged
  under the spec's own env so a spec can still override it.

- Once the identity fix let a build get far enough, it collided with E2B's
  own post-build "configuration script", which unconditionally recreates a
  default `user` account and failed outright ("group user exists") because
  the renamed account's own now-orphaned private group was still there.
  Fixed by deleting that leftover group (never the gid-100 group itself).

With all three fixed, a real build now passes `datalayer-sandbox doctor
--json` in full at build time (live-verified). A live launch through the
real launcher still fails the core tier's identity checks, though: a
fourth, deeper gap, fully root-caused and documented in the module
docstring rather than chased further here (same reasoning as
modal_sandbox.py's still-open setpriv gap, E2-05) — jupyter.service and
code-interpreter.service run as root regardless of set_user/set_workdir,
and E2B's own private main.py/jupyter_server_config.py hardcode
/home/user as the cwd, independent of which account runs the process.

29 tests, all passing; ruff/mypy clean.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
echarles added a commit that referenced this pull request Sep 13, 2026
…ule docstring

E2-03's own fixes (feat/e2b-builder) mean the build no longer refuses at
build time -- it succeeds in full. The gap moved to launch: E2B's own
jupyter.service/code-interpreter.service run as root regardless of the
build's own set_user, and E2B's private server hardcodes /home/user,
independent of anything the build sets. Updates TestE2B's docstring and
xfail reason to describe that, instead of the superseded "the build
itself refuses" story -- and the module docstring's "everything is
deleted" claim, which was never quite true for E2B (no template-delete
call exists) and matters more now that a live run actually reaches a real
template every time.

Text-only, like the previous Modal xfail correction: this branch's own
adapters are still the pre-E2-03/E2-05 stubs (cut from main), so nothing
here is runnable proof -- it keeps this file honest about what to expect
once #29 and #31 merge.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants