environments: the E2B builder — build, inspect, exists (E2-03) - #29
environments: the E2B builder — build, inspect, exists (E2-03)#29echarles wants to merge 3 commits into
Conversation
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]>
There was a problem hiding this comment.
🟡 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-v1using 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, whileget_tagscan still raise a raw authentication or transport exception. Normalize provider failures toEnvironmentsErrorhere 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 syncsrequest.lock_text, capability validation must reject an empty lock before work is queued._own_findingsstill ignoreslock_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'slocked_versionscheck.
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
e2bSDK is a local capability/dependency problem, not an unmapped provider failure. UsingPROVIDER_ERRORhere makes the error retryable (PROVIDER_ERRORis retryable inerrors.py), unlike the Datalayer adapter'sCAPABILITY_UNSUPPORTEDhandling 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.
| .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}", |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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) |
There was a problem hiding this comment.
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.
| .run_cmd( | ||
| "uv pip sync --system --require-hashes " | ||
| f"--find-links {_WHEELHOUSE_PATH} {_LOCK_PATH}", | ||
| user="root", | ||
| ) |
There was a problem hiding this comment.
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.
| if spec.env: | ||
| chain = chain.set_envs(dict(spec.env)) |
There was a problem hiding this comment.
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.
| 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) |
There was a problem hiding this comment.
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.
| 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. |
There was a problem hiding this comment.
Fixed in 427de45: provider_account (E2-01) is now populated from accounts.provider_account(self.variant, self._provider_secrets()) on the returned ArtifactReference.
| template_cls = self._template_cls() | ||
| template_id, name = self._ids(artifact) | ||
| tags = template_cls.get_tags(template_id or name) |
There was a problem hiding this comment.
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]>
…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]>
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 viafrom_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 fromcode-interpreter-v1instead, then runninguv pip sync --require-hashesagainst 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 viadoctor/build.py), the wheelhouse and the lock in;uv pip sync --require-hashes;files_step()'s baked files andpostInstallrun under thedatalayeraccount; 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 inArtifactReference.mutable_aliassoinspect/existsneed 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 inheritedManagedBuilder—smoke_testdeliberately: nothing on the real build workflow calls it today (durable's own_SmokeTestseam 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:
Template().copy()'s source must be relative to a context directory (Template(file_context_path=...)) — an absolute path is refused outright.code-interpreter-v1sets its own persistent default user for everyrun_cmd; root-needing steps need an explicituser="root".set_user("datalayer")must come after a realuseradd, 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-v1already holds an account at uid 1000 or gid 100 of its own, so thedatalayeraccount this builder creates lands on uid 1001, gid 1001 — not the contract's1000:100— and the content directory (chowned to the numbers asked for, not the ones the account got) reads as not writable.datalayer-sandbox doctor --jsoncorrectly 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 incode-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. Nameddl-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 offmain, before E3-05 landed on its own branch).ruff checkandmypyclean on every file touched. 17 new tests (tests/test_environment_e2b_builder.py) with aTemplate/TemplateBuilderdouble; 2 existing tests intest_environment_managed_builders.pyupdated to reflect what E2B now actually implements.🤖 Generated with Claude Code