Skip to content

[6/6] feat(template): execute COPY/ADD steps, WORKDIR dirs, e2b docs - #74

Open
JoyboyBrian wants to merge 6 commits into
kvcache-ai:mainfrom
JoyboyBrian:e2b-build/06-copy-exec
Open

[6/6] feat(template): execute COPY/ADD steps, WORKDIR dirs, e2b docs#74
JoyboyBrian wants to merge 6 commits into
kvcache-ai:mainfrom
JoyboyBrian:e2b-build/06-copy-exec

Conversation

@JoyboyBrian

@JoyboyBrian JoyboyBrian commented Jul 29, 2026

Copy link
Copy Markdown
Contributor

What

Wire E2B COPY/ADD execution end to end: parse build steps, materialize referenced archives, rewrite them through the layer-5 planner, upload them to the build sandbox, extract once with tar -xpf, resolve --chown, enforce aggregate budgets, create WORKDIR directories, and document template builds.

Layer PR Branch Scope
1/6 #69 e2b-build/01-error-envelope JSON 404 envelope for unmatched routes
2/6 #70 e2b-build/02-alias-rebuild Alias rebuild semantics
3/6 #71 e2b-build/03-build-file-store Build-context archive store
4/6 #72 e2b-build/04-upload-api Upload API and configuration
5/6 #73 e2b-build/05-copy-plan Host-side COPY planning
6/6 #74 e2b-build/06-copy-exec COPY/ADD execution and documentation

Why

The maintainer asked that the full change in #28 be split into smaller reviewable PRs. This is layer 6 of that six-PR stack and completes the user-visible E2B template-build workflow on top of layers 3–5.

Related issue

Related: #28

#28 remains the full reference PR containing the design discussion, prior review history, and two-node E2E evidence.

Scope and non-goals

This layer includes the coupled COPY/ADD build-spec, API mapping, archive materialization, guest upload/extraction, WORKDIR fixes, sandbox trait plumbing, and the complete E2B template-build documentation section. It removes layer 5's temporary dead_code allowance. The Copy enum variant couples these files; splitting them further would leave intermediate branches uncompilable.

Until the preceding PRs merge, the Files view also shows their commits (this branch stacks on them); this layer itself = the last commit, or JoyboyBrian/AgentENV@e2b-build/05-copy-plan...e2b-build/06-copy-exec.

Design and behavior changes

The builder collects unique referenced file hashes, enforces archive-count and aggregate-size limits, and materializes them through the repository store. The executor resolves optional user/group names inside the build VM, rewrites each archive on the host, creates a missing destination root when needed, uploads through envd, and extracts with ownership/mode preservation and --no-overwrite-dir. WORKDIR now creates its directory and resolves relative paths from the current workdir.

Compatibility and operations

  • Public API or generated protocol: Existing template build requests now accept validated E2B COPY/ADD step shapes instead of returning the previous explicit unsupported-step 400.
  • Configuration or defaults: Consumes the [template_build] budgets introduced in layer 4; no additional settings.
  • Snapshot manifest, artifact layout, or storage format: Reads layer-3 build-context archives; committed snapshot manifest structure remains unchanged.
  • Upgrade and rollback: Additive template-build behavior. Rollback returns COPY/ADD steps to unsupported status; snapshots already built remain normal committed snapshots.
  • Host requirements, permissions, ports, or dependencies: Uses existing envd file upload and guest tar; no new host dependency or port.

Validation

  • make fmt
  • make clippy
  • make test-unit
  • cargo clippy --workspace --all-targets --all-features -- -D warnings
  • cargo test -p agentenv --lib
  • Relevant Rust integration tests
  • make -C services test (required when services/ changes)
  • Generated clients/server regenerated with the documented make target
  • Documentation updated
  • Benchmarks or performance comparison completed

Commands and results:

make fmt
cargo clippy --workspace --all-targets --all-features -- -D warnings
cargo test -p agentenv --lib
git diff e2b-build/06-copy-exec e2b-build/00-source-rebased
git diff --stat upstream/main...e2b-build/06-copy-exec

All format, clippy, and library-test commands pass in the Linux verification environment. Focused unit tests cover COPY/ADD request validation, hash collection, archive budgets/materialization, chown resolution, extraction commands, WORKDIR creation/resolution, and executor error paths. The top-tree diff against branch 00 is empty and its +4852/−129 stat matches the rebased-source baseline after the three-line semantic rebase fix. #28 retains the successful two-node E2E template-build evidence.

Skipped checks and reasons: KVM/network-namespace integration suites and benchmarks were not rerun during redistribution; #28 retains the already-completed two-node E2E evidence. No services code changed.

Risks and reviewer notes

The main review surfaces are build-archive resource bounds, guest-path safety delegated to the reviewed planner, --chown identity resolution, and extraction semantics. Review this layer through the last commit or compare link. Merge only after layers 1–5.

Checklist

  • The PR contains one coherent change and no unrelated formatting or refactoring.
  • New behavior is covered by tests, or I explained why testing is impractical.
  • Logs and examples contain no credentials, tokens, or private registry information.
  • I did not manually edit generated code without updating its source and regenerating it.

@github-actions

github-actions Bot commented Jul 29, 2026

Copy link
Copy Markdown
Contributor

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

  • ✅ Successfully posted inline: 19 comment(s)

⚠️ 19 warning(s) occurred during review.


⚠️ Warnings:

  • src/api/generated/src/apis/templates.rs (subtask_error): LLM completion error: POST "https://api.madsys.dev/v1/chat/completions": 429 Too Many Requests {"message":"Too many pending requests, please retry later","type":"rate_limit_error"}
  • config/default.toml (subtask_error): LLM completion error: POST "https://api.madsys.dev/v1/chat/completions": 429 Too Many Requests {"message":"Too many pending requests, please retry later","type":"rate_limit_error"}
  • src/api/impls/mod.rs (subtask_error): LLM completion error: POST "https://api.madsys.dev/v1/chat/completions": 429 Too Many Requests {"message":"Too many pending requests, please retry later","type":"rate_limit_error"}
  • src/api/generated/src/server/mod.rs (subtask_error): LLM completion error: POST "https://api.madsys.dev/v1/chat/completions": 429 Too Many Requests {"message":"Too many pending requests, please retry later","type":"rate_limit_error"}
  • src/api/proxy.rs (subtask_error): LLM completion error: POST "https://api.madsys.dev/v1/chat/completions": 429 Too Many Requests {"message":"Too many pending requests, please retry later","type":"rate_limit_error"}
  • src/api/generated/src/models.rs (subtask_error): LLM completion error: POST "https://api.madsys.dev/v1/chat/completions": 429 Too Many Requests {"message":"Too many pending requests, please retry later","type":"rate_limit_error"}
  • src/api/impls/template_helpers.rs (subtask_error): LLM completion error: POST "https://api.madsys.dev/v1/chat/completions": 429 Too Many Requests {"message":"Too many pending requests, please retry later","type":"rate_limit_error"}
  • src/sandbox/backend.rs (subtask_error): LLM completion error: POST "https://api.madsys.dev/v1/chat/completions": 429 Too Many Requests {"message":"Too many pending requests, please retry later","type":"rate_limit_error"}
  • src/sandbox/process.rs (subtask_error): LLM completion error: POST "https://api.madsys.dev/v1/chat/completions": 429 Too Many Requests {"message":"Too many pending requests, please retry later","type":"rate_limit_error"}
  • src/api/impls/template.rs (subtask_error): LLM completion error: POST "https://api.madsys.dev/v1/chat/completions": 429 Too Many Requests {"message":"Too many pending requests, please retry later","type":"rate_limit_error"}
  • src/cfg.rs (subtask_error): LLM completion error: POST "https://api.madsys.dev/v1/chat/completions": 429 Too Many Requests {"message":"Too many pending requests, please retry later","type":"rate_limit_error"}
  • src/api/server.rs (subtask_error): LLM completion error: POST "https://api.madsys.dev/v1/chat/completions": 429 Too Many Requests {"message":"Too many pending requests, please retry later","type":"rate_limit_error"}
  • src/sandbox/envd.rs (subtask_error): LLM completion error: POST "https://api.madsys.dev/v1/chat/completions": 429 Too Many Requests {"message":"Too many pending requests, please retry later","type":"rate_limit_error"}
  • src/snapshot/repository/backends/oss/mod.rs (subtask_error): LLM completion error: POST "https://api.madsys.dev/v1/chat/completions": 429 Too Many Requests {"message":"Too many pending requests, please retry later","type":"rate_limit_error"}
  • src/snapshot/repository/backends/oss/repository.rs (subtask_error): LLM completion error: POST "https://api.madsys.dev/v1/chat/completions": 429 Too Many Requests {"message":"Concurrency limit exceeded for user, please retry later","type":"rate_limit_error"}
  • src/snapshot/repository/backends/posixfs/mod.rs (subtask_error): LLM completion error: POST "https://api.madsys.dev/v1/chat/completions": 429 Too Many Requests {"message":"Concurrency limit exceeded for user, please retry later","type":"rate_limit_error"}
  • src/snapshot/manager.rs (subtask_error): LLM completion error: POST "https://api.madsys.dev/v1/chat/completions": 429 Too Many Requests {"message":"Too many pending requests, please retry later","type":"rate_limit_error"}
  • src/snapshot/repository/mod.rs (subtask_error): LLM completion error: POST "https://api.madsys.dev/v1/chat/completions": 429 Too Many Requests {"message":"Too many pending requests, please retry later","type":"rate_limit_error"}
  • src/snapshot/repository/backends/posixfs/catalog.rs (subtask_error): LLM completion error: POST "https://api.madsys.dev/v1/chat/completions": 429 Too Many Requests {"message":"Concurrency limit exceeded for user, please retry later","type":"rate_limit_error"}

Comment thread src/api/build_files.rs
Comment on lines +97 to +103
// Verification does not consume the grant: consumption happens only after
// the archive has been durably published, so an upload that fails while
// streaming, staging, or storing the body stays retryable with this URL.
let now_unix = chrono::Utc::now().timestamp();
let authorized = match store
.verify_upload_grant(&query.token, &template_id, &hash, query.expires, now_unix)
.await

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

[security · high]
The verify-then-claim flow lets every concurrent request using the same bearer token pass verification and stage up to files_max_upload_mib. A single leaked or reused URL can therefore create an unbounded number of large temporary files and storage imports before one request finally claims the grant. Single-use enforcement after publication protects the response semantics but not disk, bandwidth, or worker resources. Use an atomic grant state transition (for example, available -> uploading with a bounded lease), release it on pre-publication failure, and consume it after successful publication so only one upload can stream per token.

Comment thread src/api/build_files.rs
Comment on lines +192 to +199
if let Err(error) = file.flush().await {
warn!(error = %error, "failed to flush staged build archive");
return Err(error_response(
StatusCode::INTERNAL_SERVER_ERROR,
"failed to stage build archive",
));
}
Ok(total)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

[bug · medium]
A zero-length body is treated as a successful archive and then permanently published under a first-write-wins cache key. An accidental empty PUT can consume the grant and poison this hash until retention removes it; subsequent correct uploads cannot replace it. Reject total == 0 before import (and ideally validate that the completed file is a structurally valid supported tar archive before publishing it).

Suggestion:

Suggested change
if let Err(error) = file.flush().await {
warn!(error = %error, "failed to flush staged build archive");
return Err(error_response(
StatusCode::INTERNAL_SERVER_ERROR,
"failed to stage build archive",
));
}
Ok(total)
if total == 0 {
return Err(error_response(
StatusCode::BAD_REQUEST,
"build archive body must not be empty",
));
}
if let Err(error) = file.flush().await {
warn!(error = %error, "failed to flush staged build archive");
return Err(error_response(
StatusCode::INTERNAL_SERVER_ERROR,
"failed to stage build archive",
));
}
Ok(total)

Comment thread src/api/build_files.rs
Comment on lines +202 to +206
let total = match tokio::time::timeout(upload_timeout, consume_body).await {
Ok(Ok(total)) => total,
Ok(Err(response)) => return response,
Err(_) => {
debug!(template_id, hash, "build archive upload timed out");

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

[bug · medium]
The configured request timeout only covers reading the client body. store.import below can perform another full file copy or OSS upload with no deadline, so a request can run far beyond files_upload_timeout_secs and hang indefinitely on backend I/O despite the setting being documented as the maximum duration of the upload request. Apply one deadline to the complete staging/import/claim operation (using remaining time after body receipt), while preserving cleanup and retry semantics on timeout.

Comment on lines +82 to +85
self.client
.put_file(&key, staged)
.await
.map_err(|error| RepositoryError::backend("upload build archive", error))

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

[security · high]
The preceding exists check is not atomic with this unconditional PUT. Two imports can both observe a miss, after which the later PUT replaces the earlier archive, violating the trait's first-write-wins guarantee and potentially changing the input of an in-flight build. Because the key is global and the caller-supplied hash is not verified against staged, a caller can also seed or race arbitrary bytes under a hash later reused by another template. Verify a cryptographic digest of the staged archive against a strict digest-shaped hash before publishing (so racing writes are content-identical), or use storage that provides an atomic create-if-absent operation; otherwise namespace the cache by an authorization boundary and do not claim immutability.

Comment on lines +163 to +167
self.client
.delete(&key)
.await
.map_err(|error| RepositoryError::backend("consume upload grant", error))?;
Ok(true)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

[security · high]
This read-authorize-delete sequence does not make the grant single-use. Concurrent requests can both read and authorize the object before either deletion is visible, and an idempotent object-store delete lets both calls return true. The upload handler consequently accepts replayed bearer URLs despite its single-winner assumption, permitting duplicate resource consumption and, together with the non-atomic archive PUT, conflicting content. Use an atomic claim primitive in a transactional/coordination store; if OSS cannot provide one, this backend should not advertise single-use grants (or the API/trait must explicitly model and mitigate replay rather than returning success to every racer).

Comment thread src/template/copy_plan.rs
Comment on lines +229 to +235
fn normalize_src(src: &str) -> String {
let mut src = src.trim();
while let Some(stripped) = src.strip_prefix("./") {
src = stripped;
}
src.trim_end_matches('/').to_string()
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

[bug · medium]
This does not implement Docker's source-path normalization. Docker strips a leading / and removes parent navigation from context-relative sources (for example, COPY /foo /dst and COPY ../foo /dst both select foo), whereas this leaves those prefixes intact. Since archive entries are context-relative, map_entries will reject valid SDK archives with “does not belong”/“does not match”. Normalize source components by discarding root/current/parent prefixes before matching, while preserving glob characters within normal components; add coverage for /foo, ../foo, and dir/../foo.

Comment thread src/template/copy_plan.rs
Comment on lines +475 to +476
let mapped: Vec<String> = if single_file_src {
vec![if dest_is_dir_hint {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

[bug · medium]
There is no validation for a glob resolving to multiple source items with a non-directory destination spelling. Docker requires the destination to end in / when a wildcard expands to multiple sources; this implementation instead falls into the glob branch, marks the destination as a directory, creates it, and succeeds (for example, COPY *.txt /renamed with two matches). Reject multi-source expansions unless dest_is_dir_hint is true, rather than silently changing an invalid Docker instruction into a directory copy.

Comment thread src/template/copy_plan.rs
Comment on lines +476 to +482
vec![if dest_is_dir_hint {
// The base name has to come from the resolved entry: the source
// may be a pattern, which is never a valid path component.
join_abs(&dest, base_name(&index[0].path))
} else {
dest.clone()
}]

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

[bug · high]
A single-file destination is also treated as a directory when that path already exists as a directory in the image, even if the instruction has no trailing slash (for example, COPY tool /usr/local/bin must create /usr/local/bin/tool when /usr/local/bin exists). This host-only decision always rewrites the entry as usr/local/bin, so extraction will fail against the existing directory instead of applying Docker semantics. Preserve enough information for the guest to choose dest/basename when dest is an existing directory, or inspect the guest filesystem before finalizing this mapping; add tests for both existing-directory and absent-path cases.

Comment on lines +207 to +209
let plan = plan_copy_archive(
&CopyRequest {
source_tar: archive,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

[performance · medium]
plan_copy_archive synchronously reads/decompresses the source twice and writes the rewritten tar from this async method. Build contexts can be hundreds of MiB, and the runner uses a current-thread Tokio runtime, so this blocks all sandbox I/O and timers on that runtime for the duration. Move archive planning (including tempfile creation if convenient) into tokio::task::spawn_blocking, passing owned paths/request fields into the closure and preserving the step context when joining it.

Comment on lines +207 to +212
let plan = plan_copy_archive(
&CopyRequest {
source_tar: archive,
src,
dest,
workdir: &context.workdir,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

[bug · high]
A single-file COPY cannot be mapped correctly without checking the guest filesystem. Docker treats an existing directory destination as a directory even when the destination has no trailing slash (for example, COPY a /tmp creates /tmp/a). plan_copy_archive currently relies only on the destination string, so it maps this case to the archive member tmp; extraction then tries to replace the existing /tmp directory with a regular file and fails. Probe the resolved destination in the sandbox before planning (without following unsafe paths), and pass whether it is an existing directory into the planner so the source basename is appended.

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.

1 participant