Skip to content

[5/6] feat(template): host-side COPY plan (archive rewrite to guest paths) - #73

Open
JoyboyBrian wants to merge 5 commits into
kvcache-ai:mainfrom
JoyboyBrian:e2b-build/05-copy-plan
Open

[5/6] feat(template): host-side COPY plan (archive rewrite to guest paths)#73
JoyboyBrian wants to merge 5 commits into
kvcache-ai:mainfrom
JoyboyBrian:e2b-build/05-copy-plan

Conversation

@JoyboyBrian

@JoyboyBrian JoyboyBrian commented Jul 29, 2026

Copy link
Copy Markdown
Contributor

What

Add the pure host-side COPY planner that rewrites an uploaded build-context tar into absolute guest paths using Docker-style source/destination semantics, segment-aware globs, two-pass streaming, and entry/byte/escape safeguards.

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 5 of that six-PR stack and isolates the substantial path/archive logic from the executor wiring that lands in layer 6.

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 adds only src/template/copy_plan.rs and its private module declaration. It has no runtime caller until layer 6, so it carries one temporary module-level dead_code allowance that layer 6 removes. Storage, HTTP upload, guest transfer, extraction, and build-spec wiring are outside this layer.

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/04-upload-api...e2b-build/05-copy-plan.

Design and behavior changes

The planner opens the source archive once and performs two bounded passes: the first normalizes/indexes paths and computes Docker-compatible mappings; the second streams entries into a rewritten tar with final absolute paths and requested ownership/mode metadata. It rejects traversal, unsupported entry types, invalid paths, excessive entries, and decompressed-byte budget violations.

Compatibility and operations

  • Public API or generated protocol: N/A; pure internal logic with no caller in this layer.
  • Configuration or defaults: N/A; budgets are passed as inputs.
  • Snapshot manifest, artifact layout, or storage format: N/A; the planner writes a temporary rewritten tar consumed by layer 6.
  • Upgrade and rollback: N/A until layer 6 wires execution.
  • Host requirements, permissions, ports, or dependencies: No new crate or host dependency.

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
TMPDIR=/dev/shm/agentenv-e2b-tests cargo test -p agentenv --lib

All listed commands pass in the Linux verification environment. The tmpfs TMPDIR avoids a pre-existing ETXTBSY from devmachine's shared /tmp in an unrelated credential-helper test. An isolated clippy probe against this module without its temporary allowance reports 25 expected dead_code errors, confirming the allowance is required until layer 6. Unit tests cover path resolution, globs, file/directory destinations, metadata, gzip, traversal and symlink safety, unsupported types, and entry/byte budgets. #28 retains the end-to-end COPY evidence.

Skipped checks and reasons: integration tests, service tests, code generation, docs checks, and benchmarks are outside this pure-logic layer.

Risks and reviewer notes

Archive path rewriting and decompression bounds are the security-sensitive surfaces. This layer intentionally has no executor behavior; review the last commit or compare link, with the temporary allowance removed by the next layer.

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)

⚠️ 13 warning(s) occurred during review.


⚠️ Warnings:

  • 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/build_files.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/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"}
  • 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/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/api/impls/template.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/image_export/service.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":"Concurrency limit exceeded for user, please retry later","type":"rate_limit_error"}
  • src/snapshot/repository/backends/oss/build_files.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":"Concurrency limit exceeded for user, please retry later","type":"rate_limit_error"}
  • src/snapshot/repository/backends/posixfs/backend.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/cfg.rs (subtask_error): LLM completion error: context deadline exceeded
  • src/snapshot/repository/backends/oss/repository.rs (subtask_error): LLM completion error: context deadline exceeded

Comment on lines +4209 to +4213
let validation = tokio::task::spawn_blocking(move || {
templates_template_id_files_hash_get_validation(path_params)
})
.await
.unwrap();

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 unwrap() can panic the request handler if the blocking task fails with a JoinError (for example, if it panics or is cancelled), instead of returning an HTTP error. The same .await.unwrap() pattern is repeated for JSON serialization in every response branch below. Propagate/map the JoinError to StatusCode::INTERNAL_SERVER_ERROR and log it; ideally fix the server-code generator so generated handlers consistently avoid these production-path panics.

Suggestion:

Suggested change
let validation = tokio::task::spawn_blocking(move || {
templates_template_id_files_hash_get_validation(path_params)
})
.await
.unwrap();
let validation = tokio::task::spawn_blocking(move || {
templates_template_id_files_hash_get_validation(path_params)
})
.await
.map_err(|e| {
error!(error = ?e, "validation task failed");
StatusCode::INTERNAL_SERVER_ERROR
})?;

Comment thread src/cfg.rs
Comment on lines +491 to +496
#[config(default = 4096u64)]
pub files_max_context_mib: u64,
/// Cap on the combined on-disk size of all build-context archives one
/// build spec may reference, in MiB.
#[config(default = 4096u64)]
pub files_max_build_context_mib: u64,

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 · medium]
These two advertised resource limits are not consumed anywhere in src/ (only their declarations, validation, and tests reference them). Consequently, compressed archives are accepted without the configured expansion bound being applied, and builds have no aggregate context-size cap. Wire these values into archive planning/build execution before exposing the upload feature; otherwise operators may rely on controls that currently provide no protection.

Comment thread src/cfg.rs
Comment on lines +513 to +514
#[config(env = "AENV_TEMPLATE_BUILD_PUBLIC_BASE_URL", parse_env = parse_trimmed_string)]
pub public_base_url: Option<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.

[security · high]
Leaving this optional makes the security-sensitive default depend on the request Host: the upload-link implementation builds http://{host}/...?...token=..., and the SDK then follows that URL with the build archive. If an untrusted client or intermediary can influence Host, it can redirect both the bearer token and source archive to an attacker-controlled origin; the fixed http scheme also exposes them on TLS-fronted deployments. Require an explicit external base URL whenever this endpoint is enabled, or derive the origin only from trusted/allowlisted proxy configuration rather than the request header.

Comment thread src/cfg.rs
Comment on lines +882 to +888
if !matches!(parsed.scheme(), "http" | "https") {
bail!(
"invalid template_build.public_base_url {base_url:?}: scheme must be http or \
https"
);
}
if parsed.query().is_some() {

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 · medium]
The value is documented as an external origin, but this validation accepts URL userinfo such as https://user:password@example.com. The downstream builder returns the base verbatim in every upload URL, exposing those configured credentials to API clients and logs. Reject non-empty usernames/passwords (and consider explicitly defining whether path prefixes are supported) before accepting the base URL.

Suggestion:

Suggested change
if !matches!(parsed.scheme(), "http" | "https") {
bail!(
"invalid template_build.public_base_url {base_url:?}: scheme must be http or \
https"
);
}
if parsed.query().is_some() {
if !matches!(parsed.scheme(), "http" | "https") {
bail!(
"invalid template_build.public_base_url {base_url:?}: scheme must be http or \
https"
);
}
if !parsed.username().is_empty() || parsed.password().is_some() {
bail!(
"invalid template_build.public_base_url {base_url:?}: must have no credentials"
);
}
if parsed.query().is_some() {

Comment on lines +322 to +330
let record = self
.write_committed_record(
metadata.id.clone(),
metadata.alias.clone(),
metadata.resources,
committed,
metadata.source.clone(),
)
.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.

[other · high]
There is a durable inconsistency window after this await: cancellation or process termination before bind_alias leaves a committed record claiming the alias while the alias still resolves to the previous snapshot. list() reads record aliases directly, and no recovery path reconciles this state, so misleading duplicate alias claims can persist indefinitely. Please make publication recoverable/transactional (for example, persist an explicit pending-alias state and reconcile it on startup/read), rather than relying only on rollback for returned errors.

Comment on lines +73 to +75
/// archive, so an in-flight build can never observe its build context
/// change underneath it.
async fn import(&self, hash: &str, staged: &Path) -> RepositoryResult<()>;

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 contract permits a globally shared, first-write-wins cache entry to be published without checking that the archive actually represents hash. The upload route passes authenticated caller-controlled bytes directly here, and both backends reuse the entry across templates, so a malformed or malicious upload can permanently poison a known cache key and later builds will materialize the wrong context. Validate the archive against the SDK's canonical content-hash algorithm before publication (and reject mismatches); also constrain accepted keys to the actual lowercase SHA-256 format rather than 16–128 hex characters. If the SDK hash is not a digest of the tar bytes, verification needs to reproduce its canonical context hashing rather than hashing the raw tar stream.

Suggestion:

Suggested change
/// archive, so an in-flight build can never observe its build context
/// change underneath it.
async fn import(&self, hash: &str, staged: &Path) -> RepositoryResult<()>;
/// archive, so an in-flight build can never observe its build context
/// change underneath it. Before publishing, implementations must verify
/// that `staged` represents the canonical build context identified by
/// `hash` and reject a mismatch.
async fn import(&self, hash: &str, staged: &Path) -> RepositoryResult<()>;

Comment on lines +91 to +96
async fn create_upload_grant(
&self,
template_id: &str,
hash: &str,
expires_unix: i64,
) -> RepositoryResult<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.

[security · medium]
The grant lifecycle has no operation or explicit contract for deleting grants that expire without a successful claim. This occurs routinely when clients request a link but skip the PUT (and the current link endpoint creates a grant even when the archive is already present). The POSIX backend happens to prune opportunistically, but the OSS backend only delegates retention to an externally configured bucket lifecycle; without that optional policy, bearer-grant objects accumulate indefinitely and allow storage/metadata exhaustion. Add an implementation-independent expiry cleanup contract/API, or make backend initialization install/require a lifecycle rule for the upload-grant prefix.

Comment thread src/template/copy_plan.rs
Comment on lines +505 to +510
} else {
// Glob source: every matched top-level item lands inside dest. Matched
// files keep their base name; matched directories contribute their
// contents (Docker treats each matched directory like a directory
// source).
let mut mapped = Vec::with_capacity(index.len());

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 glob that resolves to multiple source items is accepted even when dest_raw does not end in /. Docker requires a multi-source COPY destination to be an explicitly directory-form path (ending in /); for example, COPY *.txt /data should fail rather than silently creating /data and placing all matches beneath it. Track the distinct matched roots and reject multiple roots when dest_is_dir_hint is false.

Comment thread src/template/copy_plan.rs
Comment on lines +539 to +543
mapped.push(if rel.is_empty() && !entry.is_dir {
join_abs(&dest, base_name(&root))
} else {
join_abs(&dest, rel)
});

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]
Each target owns another copy of the full destination prefix. Because dest is caller-controlled and its length is not charged against max_total_bytes, a long destination combined with many empty entries can amplify a small allowed archive into entry_count * dest.len() memory during planning (and long-name records can similarly make the rewritten archive exceed the documented budget). Store only per-entry relative mappings and join while streaming, or explicitly bound/charge generated target-path bytes and rewritten tar framing.

Comment thread src/template/copy_plan.rs
Comment on lines +598 to +604
let Some(target) = mapped.targets.get(seen) else {
bail!("build context archive changed while it was being rewritten");
};
seen += 1;
let Some(target) = target else {
continue;
};

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 two-pass consistency check verifies only the entry count. Keeping one file handle pins the inode but does not make its contents immutable: another writable handle can replace/reorder entries or change their types between passes, after which second-pass entries receive mappings computed for different first-pass entries. Compare each second-pass normalized path/type with its EntryIndex before using the positional target, or snapshot/lock the input before pass one.

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