Skip to content

feat(template): E2B build-context uploads, COPY/ADD steps, and alias rebuild (full PR — see stacked PRs) - #28

Closed
JoyboyBrian wants to merge 7 commits into
kvcache-ai:mainfrom
JoyboyBrian:feat/e2b-template-build-context
Closed

feat(template): E2B build-context uploads, COPY/ADD steps, and alias rebuild (full PR — see stacked PRs)#28
JoyboyBrian wants to merge 7 commits into
kvcache-ai:mainfrom
JoyboyBrian:feat/e2b-template-build-context

Conversation

@JoyboyBrian

@JoyboyBrian JoyboyBrian commented Jul 28, 2026

Copy link
Copy Markdown
Contributor

Summary

The stock E2B SDK's template builder could not build Dockerfiles containing COPY, and could not rebuild an existing alias. This PR closes both gaps so AsyncTemplate.build works end to end against AgentENV:

  • Build-context uploads. The SDK resolves every COPY through GET /templates/{templateID}/files/{hash}; AgentENV had no such route, so the SDK died on a 404 with an empty body. This PR implements the endpoint (201 + {present, url}, matching the E2B spec) plus a streaming PUT endpoint the returned URL points at. Archives are stored in the snapshot repository (posix_fs and oss backends), so in multi-node deployments any node can issue the link, accept the upload, and read it back at build time — no gateway or routing changes needed. The SDK PUTs with a bare HTTP client (no auth headers), so the URL carries a bearer grant persisted in the shared repository — the same shape as an object-store presigned URL. Grants are single-use, and stored archives are immutable: a hash addresses its content, so keeping the first archive costs nothing and means no build can observe its context change while it runs. Spent grants and expired archives are pruned opportunistically. A new optional [template_build] config section (all defaults provided) bounds the upload size, the decompressed archive size, and the URL TTL, and can override the public base URL.
  • COPY/ADD execution. The uploaded archive is rewritten host-side to final absolute guest paths per Docker COPY semantics — single file, directory contents-into-dest, globs, WORKDIR-relative destinations (including a relative WORKDIR, which now resolves against the current one), root:root ownership, --chown/--chmod — then streamed into the build sandbox via envd and extracted with a single tar -xpf. --chown is resolved to numeric ids inside the build sandbox, the way Docker resolves names against the image's own /etc/passwd, and written into the rewritten tar headers, so a copy can only ever change the files it creates. The rewrite runs in two passes (index paths, then stream each entry's bytes) so an archive is never held in memory, and rejects archives past the configured decompressed budget. Its mapping logic is a pure function with unit tests; ../absolute entry escapes are rejected.
  • Alias rebuilds. Requesting a build under an existing alias no longer fails with 400 … cannot rebind. The alias keeps resolving to the previous template while the new build runs, and moves atomically when the new build commits (E2B rebuild semantics). A failed rebuild leaves the old binding untouched; the previous template stays addressable by id. Implemented in both backends, with the alias bind reordered to be the final fallible operation of publish.
  • Error envelope. Unmatched control-plane routes now return the spec's {"code":404,"message":"…"} instead of an empty body, so future gaps surface as a readable 404 rather than a client-side JSON parse error (separate first commit).

Behavior notes

  • A failed rebuild keeps the requested name on the errored record so listings show what failed; alias resolution is owned by the alias binding and keeps pointing at the previous, working template.
  • COPY app.py /opt where /opt is an existing directory fails the build with a clear error; write directory destinations with a trailing slash (/opt/), the style E2B's own docs use. Documented in docs/src/integration/e2b.md.
  • COPY --chown applies only to the files the copy creates, and an unknown user fails the build rather than silently landing as root.
  • COPY requires tar in the base image (RUN already requires /bin/bash); documented.
  • Verifying that an upload matches its filesHash was considered and rejected: the SDK derives that hash from the COPY arguments plus per-file metadata and content in its own traversal order, not from the archive bytes, so recomputing it server-side would pin the server to SDK internals. Single-use grants and immutable archives close the same window without that coupling.
  • envd currently answers the files-upload endpoint with a 2xx text/plain body while the generated client expects JSON; the upload path tolerates the decode error after a completed upload (commented inline — the root fix belongs in the envd http-client spec).

Testing

  • make fmt, cargo clippy --workspace --all-targets --all-features -- -D warnings, and the agentenv/envd/linux-cap unit suites. New unit tests cover the Docker COPY plan semantics (gzip, globs, ownership, path escapes, the decompressed budget), the build-file stores, single-use upload grants and their pruning, archive immutability, and alias rebind behavior in create/publish.
  • End-to-end on a two-node docker-compose deployment (gateway + scheduler + two runtime nodes) with E2B Python SDK 2.35.0: from_image, from_dockerfile without COPY, from_dockerfile with COPY, alias rebuild, cold sandboxes, and the full exec/files runtime suite all pass. Separately verified in a booted sandbox that COPY --chown leaves pre-existing files under the destination untouched, that a relative WORKDIR resolves against the previous one, that an upload URL is rejected on replay, and that a stored archive keeps its original bytes across a repeat upload on both nodes.
  • Generated server code regenerated from the spec change via make agentenv-server.

Unmatched control-plane routes fell through to the proxy fallback and
returned 404 with an empty body, so JSON clients (including the E2B SDK)
surfaced a bare JSONDecodeError instead of "not found". Return the
spec's error envelope from the fallback so unimplemented routes
self-diagnose.
…rebuild

Close the two gaps that kept the stock E2B SDK template builder from
working end to end: Dockerfiles with COPY had no build-context upload
endpoint (the SDK died on a bare 404), and rebuilding an existing alias
was rejected with 400.

- Implement GET /templates/{templateID}/files/{hash} (201 + {present,
  url}, matching the E2B spec) plus a streaming PUT endpoint the
  returned URL points at. Archives are stored in the snapshot repository
  (posix_fs and oss), so any node can issue the link, accept the upload,
  and read it back at build time; multi-node deployments need no routing
  changes. The SDK PUTs with no auth headers, so the URL carries a
  short-lived bearer grant persisted in the shared repository, and spent
  grants plus expired archives are pruned opportunistically.
- Execute COPY/ADD steps by rewriting the uploaded archive host-side to
  final absolute guest paths per Docker COPY semantics (single file,
  directory contents, globs, WORKDIR-relative dest, root:root
  ownership, --chown/--chmod), then streaming it into the build sandbox
  via envd and extracting with a single tar -xpf. The rewrite is a pure
  function with unit tests; path escapes are rejected.
- Allow alias rebuilds: the alias keeps resolving to the previous
  template while the new build runs and moves atomically when the build
  commits (E2B semantics). Failed builds leave the old binding
  untouched, and the previous template stays addressable by id. The
  alias bind is now the final fallible operation of publish in both
  backends.
- Tolerate envd's text/plain response to the files upload endpoint; the
  generated client expects JSON and the upload has already completed
  when the decode error surfaces.
Match the long-line style used everywhere else in the docs tree.
Review findings on the build-context work, all in the COPY path:

- `--chown` was applied as `chown -R` over the destination after
  extraction, so it also rewrote every pre-existing file underneath it
  (`COPY --chown=u app/ /usr/local/` re-owned all of /usr/local, and a
  destination of / re-owned the rootfs). Ownership is now resolved to
  numeric ids inside the build sandbox — the way Docker resolves names,
  against the image's own /etc/passwd and /etc/group — and written into
  the rewritten tar headers, so a copy can only touch what it creates.
  An unknown user now fails the build instead of silently landing as
  root, and the uploader's account names are cleared from the headers so
  they cannot bind to an unrelated guest account at extraction time.
- The archive rewrite read every entry fully into memory and kept them
  all live, so a compressed upload could expand without bound on the
  node running the build. The rewrite now runs in two passes — index
  paths, then stream each entry's bytes — and rejects archives past a
  decompressed budget (`template_build.files_max_context_mib`, 4 GiB by
  default) or an unreasonable entry count.
- A relative `WORKDIR` was stored verbatim, which left it undefined for
  RUN steps and made any later relative COPY destination fail. It now
  resolves against the current working directory like Docker.
- A GNU sparse entry is read back expanded, so the rewritten entry is
  now marked as a plain regular file rather than keeping a sparse type
  it no longer matches.
An upload grant was validated but never consumed, so an upload URL kept
working for its whole TTL and could re-upload under the same hash;
`import` overwrote unconditionally, which could also swap an archive out
from under a build already reading it.

Claiming a grant now consumes it — on POSIX the `remove_file` that
consumes it is the atomic claim, so concurrent replays of one token
cannot both win — and stored archives are immutable. Since a hash
addresses its content, keeping the first archive costs nothing and means
no build can observe its context change while it runs.

Verifying that an upload matches its `filesHash` was considered instead
and rejected: the SDK derives that hash from the COPY arguments plus
per-file metadata and content in its own traversal order, not from the
archive bytes, so recomputing it server-side would pin us to SDK
internals.
@JoyboyBrian

Copy link
Copy Markdown
Contributor Author

Thanks for approving the earlier run! I gave the PR another read-through afterwards and pushed two follow-up commits — sorry, that means it needs approving once more on the new head.

  • a9227cb scopes COPY --chown to the files a copy actually creates (it previously ran chown -R over the destination, re-owning pre-existing files underneath it), streams the archive rewrite instead of holding entries in memory, and resolves a relative WORKDIR against the current one.
  • fb24859 makes upload URLs single-use and stored archives immutable.

make fmt, clippy and the unit suites pass locally, and the two-node end-to-end run is unchanged. Happy to restructure any of it if that would read better.

@github-actions

github-actions Bot commented Jul 28, 2026

Copy link
Copy Markdown
Contributor

🔍 OpenCodeReview found 33 issue(s) in this PR.

  • ✅ Successfully posted inline: 33 comment(s)

⚠️ 2 warning(s) occurred during review.


⚠️ Warnings:

  • src/cfg.rs (subtask_error): LLM completion error: context deadline exceeded
  • src/template/copy_plan.rs (subtask_error): context deadline exceeded

Comment thread config/default.toml
Comment thread src/api/build_files.rs Outdated
Comment thread src/api/build_files.rs Outdated
Comment thread src/api/build_files.rs Outdated
Comment thread src/api/build_files.rs Outdated
Comment thread src/template/copy_plan.rs Outdated
Comment thread src/template/copy_plan.rs
Comment thread src/template/step_executor.rs
Comment thread src/template/step_executor.rs
Comment thread src/template/step_executor.rs Outdated
WORKDIR now creates its directory in the build sandbox (mkdir -p,
matching Docker), so a template built from a Dockerfile whose WORKDIR
names a path absent from the base image no longer records a default
cwd that does not exist in the guest.

Review fixes, validated comment by comment against the code:

- copy_plan: polynomial, segment-aware glob matching (was exponential
  and matched across '/'); the source archive is opened once and both
  passes read a capped reader, so GNU long-name/PAX records cannot
  allocate unboundedly; per-entry tar framing counts against the
  context budget; non-UTF-8 entry names are rejected.
- COPY: extraction uses --no-overwrite-dir and no header is emitted
  for the destination root, so pre-existing directories keep their
  metadata while a missing destination is created with the requested
  --chown/--chmod; chown specs reject option-like parts.
- Upload API: grants are verified up front but consumed only after the
  body is staged, so recoverable failures no longer burn the single-use
  URL; staging moves off the async worker; uploads time out via
  template_build.files_upload_timeout_secs; expiry math saturates.
- Build-file stores: posixfs publishes via no-clobber hard_link,
  prunes grants by their own expiry, maps I/O errors instead of
  swallowing them, and refreshes archive mtimes on use; OSS
  materialize treats NotFound as a miss without a pre-check race.
- Template API: filesHash on non-COPY/ADD steps, modes above 0o7777,
  and over-long COPY sources are rejected with 400s.
- Catalog: alias rebinds take the previous owner's record lock (alias
  before record, documented); clearing a moved alias is guarded by the
  alias value on both backends; added a failure-path publish test.
- Builder: a spec references at most 32 distinct archives, bounded in
  total by template_build.files_max_build_context_mib.
- Config: public_base_url is validated at startup and documented as
  required behind TLS termination (upload URLs carry a bearer token);
  empty env values mean unset; new knobs are bounded at load time.

Deferred (tracked in review replies): per-template archive namespacing
and template-scoped authorization (blocked on the tenant model),
conditional-write single-use grants on OSS, and alias reconciliation
for duplicate listings.
@JoyboyBrian

Copy link
Copy Markdown
Contributor Author

All 48 review comments have been triaged — each one validated against the actual code — and every thread is now resolved. The fixes landed in b632852, together with one separately-found bug: WORKDIR never created its directory in the built template, so any Dockerfile whose WORKDIR names a path absent from the base image produced a template whose first command fails with cwd does not exist. Verified end-to-end on a two-node deployment: bare FROM ubuntu:24.04 + WORKDIR /app now yields an existing /app and the correct default cwd.

Fixed (37 comments). Highlights by area:

  • copy_plan: glob matching rewritten to be polynomial and segment-aware (*/?/[...] no longer cross /); the context archive is opened once and both passes read through a capped reader, so GNU long-name/PAX records can no longer allocate unboundedly; per-entry tar framing counts against the decompressed budget; non-UTF-8 entry names are rejected.
  • COPY semantics: extraction runs with --no-overwrite-dir and the plan never emits a header for the destination root — pre-existing directories keep their metadata, while a missing destination is created with the requested --chown/--chmod.
  • Upload API: grants are verified up front but consumed only after the body is fully staged, so recoverable failures no longer burn the single-use URL; uploads get a config-bounded timeout; staging-file creation moved off the async worker; expiry arithmetic saturates.
  • Build-file stores: posixfs publishes archives via no-clobber hard_link, prunes grants by their own expiry, maps I/O errors instead of swallowing them, and refreshes archive mtimes so retention cannot reap in-use archives; OSS materialize treats NotFound as a miss instead of racing an exists() pre-check.
  • API validation: filesHash on non-COPY/ADD steps, modes above 0o7777, and over-long COPY sources are rejected with 400s; the [template_build] knobs are bounded at startup, and public_base_url is validated and documented as required behind TLS termination (upload URLs carry a bearer token).
  • Catalog/builder: alias rebinds take the previous owner's record lock (lock order documented); clearing a moved alias is guarded by the alias value on both backends; a failure-path publish test was added; one build spec is bounded to 32 distinct archives and a total-size budget.

Validation: cargo fmt, cargo clippy --workspace --all-targets --all-features -D warnings, and the unit suite are green on Linux (the only failures are pre-existing environment-dependent tests that fail identically on the unpatched base), plus the E2E WORKDIR spike above.

Pushed back (8 comments, detailed replies in the threads). The common reasons: the described attack path is not reachable at HEAD (publish paths either mint fresh UUIDs or serialize through try_start_build; the spoofed-Host "exfiltration" returns the URL only to the authenticated caller who sent the header), the flagged behavior deliberately matches Docker (--chown numeric gid) or the E2B wire contract (201 on the upload-link GET), or the requested property is vacuous because the server never verifies the client-derived hash in the first place.

Deferred (3 threads, each with a note). Per-template archive namespacing and template-scoped authorization are blocked on the tenant model (credential validation is an acknowledged TODO in auth.rs); one finding targets machine-generated code and belongs in the generator; one micro-optimization depends on SDK behavior we could not confirm.

Comment thread src/api/build_files.rs
Comment thread src/api/build_files.rs Outdated
Comment thread src/api/generated/src/server/mod.rs
Comment thread src/api/generated/src/server/mod.rs
Comment thread src/api/impls/template_helpers.rs
Comment thread src/template/copy_plan.rs Outdated
Comment thread src/template/copy_plan.rs Outdated
Comment thread src/template/step_executor.rs
Comment thread src/template/step_executor.rs
Comment thread src/template/step_executor.rs
Upload lifecycle: the handler now runs verify -> stage -> import ->
claim, so a failed store leaves the single-use URL retryable; the
grant is consumed only after the archive is durably published (safe:
the token binds one template_id/hash and import is first-write-wins).
posixfs fsyncs the staged archive before the hard-link publish, caps
opportunistic pruning at 256 entries per call, and catalog create is
all-or-nothing under the alias lock.

copy_plan: the capped-reader slack scales with the configured budget
instead of a fixed ~195 MiB; a glob resolving to exactly one regular
file gets single-file destination semantics with the base name taken
from the resolved entry; an explicit "dest/" directory destination is
prepared for single-file copies without inheriting the file's --chmod
mode.

Validation and errors: filesHash is shape-checked at the API edge
with the store's own validator; files_max_upload_mib and
files_max_context_mib reject zero at startup; envd upload errors keep
their source chain; a failed COPY ownership lookup no longer asserts
the user does not exist.
@JoyboyBrian

Copy link
Copy Markdown
Contributor Author

Round 2 (35 comments) triaged the same way as round 1: every comment validated against the code, using the diff against main to separate what this PR introduces from pre-existing behavior. All threads are resolved; fixes landed in 6f4b6a5.

Fixed (13).

  • Upload lifecycle is now verify → stage → import → claim: a failed store leaves the single-use URL retryable, and consuming the grant after publication is safe because the token binds one (template_id, hash) and import is first-write-wins.
  • posixfs: staged archives are fsynced before the hard-link publish; pruning is bounded per call; catalog create is all-or-nothing under the alias lock.
  • copy_plan: the capped-reader slack scales with the configured budget (was a fixed ~195 MiB); a glob matching exactly one file gets single-file destination semantics; an explicit dest/ is prepared for single-file copies without inheriting the file's --chmod.
  • Validation: filesHash shape-checked at the API edge; zero-value config knobs rejected at startup; envd errors keep their source chain; ownership-lookup failures no longer claim the user is absent.

fmt / clippy (--workspace -D warnings) / unit suite are green on Linux, same pre-existing environment failures as the unpatched base.

Pushed back (10, replies in threads). Mostly round-1 re-raises without new arguments (Host-header URL, numeric --chown, catalog rollback premise, 201-on-GET wire compat); the rest contradict the code — one even asked to consume the grant earlier, which would reintroduce the burned-URL problem round 1 fixed.

Deferred (12, each answered in its thread). Three known boundaries, acknowledged in code/trait docs: content verification and tenant scoping of the build-file cache are blocked on the auth/tenant TODO (the hash is a caller-supplied cache key the server cannot recompute); strict single-use on OSS needs a conditional-write primitive OssClient lacks (posixfs already gives strict first-write-wins); alias/rebuild reconciliation stays the round-1 follow-up.

Comment thread src/api/build_files.rs
Comment thread src/api/build_files.rs
Comment thread src/api/generated/src/models.rs
Comment thread src/api/generated/src/models.rs
Comment thread src/api/generated/src/server/mod.rs
Comment thread src/snapshot/repository/build_files.rs
Comment thread src/template/builder.rs
Comment thread src/template/step_executor.rs
Comment thread src/template/step_executor.rs
Comment thread src/template/step_executor.rs
@JoyboyBrian

Copy link
Copy Markdown
Contributor Author

Round 3 (33 comments) was triaged with the same per-comment validation as rounds 1–2, this time against a deliberately strict bar: fix only concretely reachable correctness defects introduced by this PR. Outcome: zero code changes — all 33 threads are answered and resolved individually.

The round breaks down as: repeats of threads already answered twice (the Host-header URL, unverified-hash cache keys, OSS conditional writes, catalog rollback premise, 201-on-GET); nitpicks of the round-2 fixes whose failing scenarios are unreachable under shipped defaults (e.g. the 300s upload timeout sits well inside the 3600s grant TTL) or whose worst case is an already-documented, self-recovering condition; and one that asks to move the grant claim back before publication — the opposite of what round 2 requested and 6f4b6a5 implemented (r1: 3663195264 → r2: 3668146444 → this round: 3668561262).

Since no commit results from this round, the review loop terminates here. The full triage record is in the threads; happy to address anything a human reviewer considers substantive.

@yingdi-shan

Copy link
Copy Markdown
Collaborator

Thanks so much for the contribution! This PR is pretty large, so it'll take a while to review carefully, and we'll likely run into merge conflicts along the way. Would you mind starting with a smaller PR instead?

@JoyboyBrian

JoyboyBrian commented Jul 29, 2026

Copy link
Copy Markdown
Contributor Author

Hi @yingdi-shan ! Agreed, I split this into a six-PR chain so each layer can be reviewed and merged independently while #28 stays open as the full reference PR:

  1. #69 — [1/6] fix(api): return the JSON error envelope for unmatched routes — focused unmatched-route response fix.
  2. #70 — [2/6] feat(snapshot): E2B alias rebuild semantics on template publish — keep the previous alias live until the rebuild commits and restore it on failure.
  3. #71 — [3/6] feat(snapshot): template build-context archive store (posixfs + oss) — content-addressed archives and single-use upload grants.
  4. #72 — [4/6] feat(api): E2B build-context upload endpoints and [template_build] config — E2B upload handshake, streaming PUT, limits, and generated API.
  5. #73 — [5/6] feat(template): host-side COPY plan (archive rewrite to guest paths) — isolated, unit-tested COPY path/archive logic.
  6. #74 — [6/6] feat(template): execute COPY/ADD steps, WORKDIR dirs, e2b docs — executor wiring, WORKDIR behavior, and user documentation.

The branches are chained in that order. Until preceding PRs merge, PR k's commits/files include layers 1..k; the review scope for each PR after the first is its last commit (and each body includes a direct fork compare link for that layer). Please merge strictly 1 → 6. After each merge I will rebase the remaining chain onto main so their open diffs collapse to the unmerged layers.

#28 will remain open as the full reference for the design discussion, completed review rounds, and two-node E2E evidence until the stack lands.

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