Skip to content

feat(sandbox): add E2B-compatible secure sandbox support - #127

Merged
yingdi-shan merged 4 commits into
kvcache-ai:mainfrom
LSX-s-Software:feat/envd-access-token
Aug 14, 2026
Merged

feat(sandbox): add E2B-compatible secure sandbox support#127
yingdi-shan merged 4 commits into
kvcache-ai:mainfrom
LSX-s-Software:feat/envd-access-token

Conversation

@LSX-s-Software

@LSX-s-Software LSX-s-Software commented Aug 5, 2026

Copy link
Copy Markdown
Member

What

Introduce end-to-end support for E2B-compatible secure sandboxes. When a sandbox is created with secure: true, AgentENV generates a sandbox-specific envd access token and enforces it across the complete sandbox lifecycle.

  • Add the optional secure sandbox create field and return envdAccessToken from create, get, connect, and fork responses.
  • Pass secure tokens through Firecracker MMDS and envd initialization, and attach them to internal HTTP, Connect, aenv CLI, and E2B SDK requests.
  • Give secure fork children independent tokens and preserve token identity across pause, server restart, and resume.
  • Persist the sandbox's secure mode while keeping legacy records non-secure by default.
  • Manage a persistent node-local token seed automatically for single-node installations, while allowing an explicit shared seed for multi-node deployments.
  • Document deployment secret requirements and exercise secure mode explicitly in the E2B Python, TypeScript, and code-interpreter compatibility suites.

Why

AgentENV did not previously implement the E2B secure sandbox contract. Current E2B SDKs create secure sandboxes by default, so compatibility requires AgentENV to return an envd access token and enforce it transparently for SDK and CLI operations.

Introducing secure support should not force existing or single-node users to add configuration before upgrading. AgentENV therefore creates and reuses a managed local seed when no explicit seed is supplied, while multi-node operators can configure one shared seed through their deployment secret mechanism.

Related issue

N/A - no linked issue.

Scope and non-goals

In E2B semantics, secure authenticates envd control communication. This PR does not add authentication to arbitrary application ports or implement cross-node sandbox migration. The install script remains unchanged.

Design and behavior changes

  • Tokens are HMAC-SHA256 values derived from the effective seed and sandbox ID. The token itself is not persisted in sandbox metadata.
  • Firecracker MMDS stores only the token hash. /init receives the token in both X-Access-Token and the request body, matching envd's contract.
  • Internal envd clients and proxy routes supply or validate the token as appropriate.
  • Fork children derive tokens from their own sandbox IDs and cannot authenticate with the source sandbox's token.
  • Secure state is persisted with a serde default of false, so legacy sandbox records continue to restore as non-secure.
  • Explicit environment or TOML configuration takes precedence over managed state. Changing the effective seed rotates existing secure sandbox tokens.
  • Without an explicit seed, normal startup reads or atomically creates $AENV_HOME/secrets/sandbox-access-token-hash-seed. The directory is 0700, the file is 0600, and malformed, unreadable, symlinked, or unexpectedly missing state fails instead of rotating silently.
  • Concurrent startup uses no-clobber persistence and all contenders reuse the winning seed.

Compatibility and operations

  • Public API or generated protocol: Introduces optional secure request fields and optional envdAccessToken response fields. Omitting secure preserves the previous non-secure behavior.
  • Configuration or defaults: Adds optional [sandbox].access_token_hash_seed / AENV_SANDBOX_ACCESS_TOKEN_HASH_SEED; single-node startup auto-generates a persistent seed when unset.
  • Snapshot manifest, artifact layout, or storage format: No snapshot format change. Persisted sandbox metadata adds secure with a legacy default of false.
  • Upgrade and rollback: Existing users can upgrade without changing configuration. Preserve $AENV_HOME/secrets/sandbox-access-token-hash-seed once secure sandboxes exist; deleting or changing it invalidates their tokens.
  • Host requirements, permissions, ports, or dependencies: No new ports or host packages. Kubernetes runtime nodes require the documented agentenv-runtime-secrets Secret; the local-dev overlay provides a test-only value.

Validation

  • make fmt
  • make clippy
  • make test-unit
  • 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

Skipped checks and reasons:

  • make -C services test was not run because services/ is unchanged.
  • Benchmarks were not run because this change affects startup secret handling and request authentication rather than snapshot or steady-state data paths.

Risks and reviewer notes

  • Review the API-to-envd token flow as one new end-to-end secure sandbox contract rather than as an isolated seed-management change.
  • Review src/sandbox/access.rs for managed-secret durability, permissions, and concurrent startup behavior.
  • Review fork and resume identity replacement to ensure a child or resumed runtime cannot retain stale token metadata.
  • Multi-node deployments must use the same explicit seed on every runtime node before cross-node recovery of a sandbox ID is introduced.

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.

@LSX-s-Software LSX-s-Software changed the title feat(sandbox): support secure envd access tokens feat(sandbox): add E2B-compatible secure sandbox support Aug 5, 2026
@yingdi-shan

Copy link
Copy Markdown
Collaborator

We should add HTTPS and API-KEY authentication before merging this PR — without them, an access token alone doesn't provide sufficient security. PR #123 provides a basic draft.

@github-actions

github-actions Bot commented Aug 5, 2026

Copy link
Copy Markdown
Contributor

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

  • ✅ Successfully posted inline: 10 comment(s)

impl Client {
pub fn files(&self, sandbox_id: &str) -> Result<EnvdFilesClient> {
EnvdFilesClient::new(&self.base, &self.api_key, sandbox_id)
let sandbox = self.get_sandbox(sandbox_id)?;

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]
get_sandbox performs a synchronous ureq network request (with the client's potentially long request timeout). Both current callers invoke files() from async run_async functions, so this blocks a Tokio worker thread and can stall unrelated tasks/progress handling. Make token resolution asynchronous (for example, expose an async files constructor using an async HTTP client or spawn_blocking) or fetch the sandbox metadata before entering the runtime.

Suggestion:

Suggested change
let sandbox = self.get_sandbox(sandbox_id)?;
// Resolve sandbox metadata asynchronously before constructing this client.

}

match envd_ready_probe(client.clone(), sandbox_id.clone()).await {
match envd_ready_probe(Arc::clone(&transport)).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.

[performance · low]
The readiness probe does not retain or share ownership of the transport, so cloning the Arc on every watchdog iteration adds an unnecessary atomic reference-count operation. Borrow the transport for the duration of the awaited probe instead (envd_ready_probe(&transport) and async fn envd_ready_probe(transport: &Transport)).

Suggestion:

Suggested change
match envd_ready_probe(Arc::clone(&transport)).await {
match envd_ready_probe(&transport).await {

let rest: Vec<String> = cmd_iter.collect();

let transport = client.transport(&args.sandbox_id)?;
let sandbox = client.get_sandbox(&args.sandbox_id)?;

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]
get_sandbox performs a synchronous HTTP request (ureq::Request::call) from inside this async function, which can block the Tokio runtime thread while the control-plane request is in flight. Fetch the sandbox before entering block_on, or run this call via spawn_blocking (using an owned/cloned client and ID) so unrelated async work is not stalled.

Suggestion:

Suggested change
let sandbox = client.get_sandbox(&args.sandbox_id)?;
let sandbox_id = args.sandbox_id.clone();
let sandbox = tokio::task::spawn_blocking({
let client = client.clone();
move || client.get_sandbox(&sandbox_id)
})
.await??;

base_url: base_url.trim_end_matches('/').to_string(),
api_key: api_key.to_string(),
sandbox_id: sandbox_id.to_string(),
envd_access_token: envd_access_token.map(str::to_owned),

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 · low]
Validate the token as an HTTP header value in Transport::new, as the files client already does. Currently a token containing invalid header bytes (for example, a newline) is accepted by this fallible constructor and only surfaces later as a generic request-builder error on every RPC. Storing a validated HeaderValue also avoids repeating conversion for each request and allows an actionable invalid envd access token header value context at the API boundary.

Suggestion:

Suggested change
envd_access_token: envd_access_token.map(str::to_owned),
envd_access_token: envd_access_token
.map(reqwest::header::HeaderValue::from_str)
.transpose()
.context("invalid envd access token header value")?,

Comment thread src/orchestrator/tests.rs
Comment on lines +4470 to +4475
let child_token = orchestrator
.get_envd_access_token(child)
.expect("secure child has a token");
assert_ne!(child_token, source_token);
assert!(!child_tokens.contains(&child_token));
child_tokens.push(child_token);

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.

[test · medium]
These assertions only recompute a token from each child's metadata ID; they do not verify that the same token was supplied to the forked backend/envd. The test would still pass if fork_sandbox_inner passed None, the source token, or another incorrect token in SandboxForkSpec, because MockSandboxBackend::fork currently ignores the specs. Capture the received SandboxForkSpecs in the mock behavior and assert that each successful child's runtime token equals get_envd_access_token(child) (and that the source remains distinct).

Suggestion:

Suggested change
let child_token = orchestrator
.get_envd_access_token(child)
.expect("secure child has a token");
assert_ne!(child_token, source_token);
assert!(!child_tokens.contains(&child_token));
child_tokens.push(child_token);
let child_token = orchestrator
.get_envd_access_token(child)
.expect("secure child has a token");
assert_ne!(child_token, source_token);
assert!(!child_tokens.contains(&child_token));
// Also assert that MockSandboxBackend captured this token in the
// SandboxForkSpec used to start this child.
child_tokens.push(child_token);

Comment thread src/sandbox/firecracker/sandbox.rs
Comment thread tests/common/mod.rs
Comment on lines +29 to +30
let mut request = envd::reqwest::Client::new()
.post(format!("http://{}:{port}/process.Process/List", target.ip));

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.

[test · medium]
The unauthorized cases use a raw HTTP/1.1 Connect-style request, but the only successful case uses ProcessClient, which normally reaches envd over tonic/gRPC. This does not prove that a valid token is accepted by the same transport used for the rejection checks (and by Connect clients such as aenv); a Connect handler that rejects every request would still pass. Send a valid-token request through this helper as well and assert a successful status/body, or exercise both missing/wrong/valid tokens through one client stack.

Comment on lines +338 to 341
secure: false,
})
.await?;
assert_eq!(relaunched.state, SandboxState::Running);

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.

[test · medium]
This snapshot relaunch explicitly selects insecure mode, but the test only checks state/proxy-route availability. It never verifies that envd remains callable without X-Access-Token, so an implementation that accidentally requires authentication for all sandboxes—or retains secure auth state in a captured snapshot—could pass while breaking insecure snapshot workloads. Resolve the proxy target and assert that an unauthenticated process-list request succeeds after relaunch (ideally also before capture).

Comment thread thirdparty/envd/src/transport.rs
@LSX-s-Software

LSX-s-Software commented Aug 5, 2026

Copy link
Copy Markdown
Member Author

We should add HTTPS and API-KEY authentication before merging this PR — without them, an access token alone doesn't provide sufficient security. PR #123 provides a basic draft.

I agree that TLS is required when AgentENV is exposed over an untrusted network, but I don’t think #123 should block this PR.

#123 is still a draft, and its token model currently has several unresolved issues:

  • It derives one token and returns it as both envdAccessToken and trafficAccessToken, but E2B defines them as separate credentials for different trust boundaries, which is more reasonable.
  • It populates envdAccessToken in the API response without installing that token into envd through MMDS/init. As a result, the SDK receives a token, but envd is not actually secured by it.
  • It accepts X-Access-Token for arbitrary sandbox proxy traffic and forwards that credential to the upstream sandbox application. In E2B, X-Access-Token is for envd communication, while proxy traffic uses the separate e2b-traffic-access-token header.
  • It also explicitly excludes HTTPS/TLS from its scope, so merging feat: add single-tenant API key authentication #123 would not satisfy the proposed HTTPS prerequisite anyway.

These are separate authentication layers and should remain separate:

  1. This PR, which implements E2B-compatible secure semantics and per-sandbox envd authentication.
  2. API-key authentication protects the AgentENV control-plane API.
  3. Traffic-token authentication protects proxied sandbox ports.
  4. TLS protects all of these credentials in transit.

The safer ordering is to merge the correct envd authentication contract in this PR first, then rebase #123 and implement API-key and traffic authentication on top without conflating the credentials. TLS can be handled independently at the deployment or ingress layer.

@LSX-s-Software
LSX-s-Software force-pushed the feat/envd-access-token branch from a6b8cdd to 8fb985f Compare August 11, 2026 03:37
Comment thread crates/aenv/src/client/files.rs
Comment on lines +30 to +31
let sandbox = client.get_sandbox(&args.sandbox_id)?;
let transport = client.transport(&args.sandbox_id, sandbox.envd_access_token.as_deref())?;

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]
get_sandbox performs a synchronous ureq network call (with a timeout of up to 120 seconds) inside run_async, which runs on the CLI's current-thread Tokio runtime. This can block the runtime and prevent async cancellation or other runtime work from progressing. Fetch the sandbox before entering block_on and pass its access token into run_async, or provide an async HTTP implementation for this request.

Suggestion:

Suggested change
let sandbox = client.get_sandbox(&args.sandbox_id)?;
let transport = client.transport(&args.sandbox_id, sandbox.envd_access_token.as_deref())?;
let transport = client.transport(&args.sandbox_id, envd_access_token.as_deref())?;

Comment thread src/api/proxy.rs
Comment thread src/sandbox/access.rs
Comment on lines +88 to +94
fn validate_explicit_seed(seed: &str) -> Result<&str> {
let seed = seed.trim();
if seed.is_empty() {
bail!("[sandbox].access_token_hash_seed must be non-empty when configured");
}
Ok(seed)
}

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 accepts low-entropy values such as "a" as the HMAC key. Since tokens are deterministic over observable sandbox IDs, anyone who obtains one token can test candidate seeds offline and then derive tokens for other IDs. Require a cryptographically strong representation here (for example, exactly 64 hex characters decoding to 32 bytes) and use the decoded bytes as the HMAC key; update placeholder examples accordingly.

Comment thread src/sandbox/access.rs Outdated
Comment thread src/sandbox/firecracker/config.rs
Comment thread src/sandbox/firecracker/sandbox.rs
Comment on lines +29 to +30
let mut request = envd::reqwest::Client::new()
.post(format!("http://{}:{port}/process.Process/List", target.ip));

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.

[test · medium]
The negative authentication probe is not a valid Connect/gRPC request: it omits the protocol content type/version and encoded ListRequest body. Therefore, the observed 401 can come from handling a malformed request before the behavior of a real envd client is exercised, allowing a valid unauthenticated RPC regression to go undetected. Use ProcessClient::connect(..., None/Some("wrong-token")), invoke list, and assert that the returned RPC status is unauthenticated.

Comment thread tests/integration/orchestrator.rs
Comment thread thirdparty/envd/src/transport.rs
@LSX-s-Software
LSX-s-Software force-pushed the feat/envd-access-token branch from 8fb985f to 244e2a5 Compare August 11, 2026 15:21
impl Client {
pub fn files(&self, sandbox_id: &str) -> Result<EnvdFilesClient> {
EnvdFilesClient::new(&self.base, &self.api_key, sandbox_id)
let sandbox = self.get_sandbox(sandbox_id)?;

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]
This introduces blocking ureq network I/O inside Client::files. Both current callers invoke files() from run_async on the CLI's single-threaded Tokio runtime, so this request blocks the runtime (and cannot be cancelled) for up to the agent timeout. Keep this constructor free of I/O by passing the already-fetched access token into it, or fetch sandbox details before entering block_on; alternatively, use an async HTTP client and make this API async.

}

match envd_ready_probe(client.clone(), sandbox_id.clone()).await {
match envd_ready_probe(Arc::clone(&transport)).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.

[performance · low]
The probe only borrows the transport while awaiting ready(), so cloning the Arc on every watchdog iteration is unnecessary. Accept &Transport (or &Arc<Transport>) in envd_ready_probe and pass transport.as_ref() here; this avoids repeated atomic reference-count updates and expresses the ownership requirement more clearly.

Suggestion:

Suggested change
match envd_ready_probe(Arc::clone(&transport)).await {
match envd_ready_probe(transport.as_ref()).await {

base_url: String,
api_key: String,
sandbox_id: String,
envd_access_token: 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 · medium]
Store this secret as a sensitive HeaderValue, rather than a plain String. The current .header("X-Access-Token", token) conversion creates a non-sensitive value, so request/header diagnostics may expose the token; it also defers invalid-header errors until the request is built or sent even though new already returns Result. Parse and mark it sensitive in new, as the files client does.

Suggestion:

Suggested change
envd_access_token: Option<String>,
envd_access_token: Option<reqwest::header::HeaderValue>,

Comment thread src/api/proxy.rs
Comment on lines +758 to +760
if target_port != ConfigManager::global_config().tools.control_plane_port {
return Ok(());
}

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]
This compares against the current process-global port, not the paused sandbox's effective envd port. FirecrackerCommonConfig.control_plane_port is persisted in paused state and can differ after a configuration change/restart. In that case, a request targeting the sandbox's old envd port skips this check and can trigger an unauthenticated auto-resume (envd will reject the eventual request, but the protected resume side effect has already occurred). Persist/expose the effective control-plane port in sandbox metadata and compare against that value, or otherwise ensure secure envd auto-resume authorization cannot be bypassed by config drift.

Suggestion:

Suggested change
if target_port != ConfigManager::global_config().tools.control_plane_port {
return Ok(());
}
if target_port != metadata.control_plane_port {
return Ok(());
}

Comment thread src/sandbox/access.rs
Comment on lines +54 to +56
if let Some(seed) = config.sandbox.access_token_hash_seed.as_deref() {
return Self::new(seed);
}

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 silently replaces an existing managed seed whenever an explicit cluster seed is configured. Persisted secure sandboxes were initialized with tokens derived from the old managed seed; after restart, the orchestrator derives a new token while the resumed envd state still authenticates with the old one, so recovery/access fails. This is especially likely during the documented migration to cross-node recovery. Detect an existing managed seed and reject a mismatched explicit seed, or persist/version a seed fingerprint and provide an explicit migration/rotation path.

Comment thread src/sandbox/access.rs
Comment on lines +90 to +96
fn validate_explicit_seed(seed: &str) -> Result<&str> {
let seed = seed.trim();
if seed.is_empty() {
bail!("[sandbox].access_token_hash_seed must be non-empty when configured");
}
Ok(seed)
}

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 configured value is the HMAC authentication key, but this validation accepts low-entropy values such as "a" (and the sample configuration only says “secret”). Since sandbox IDs and derived tokens can be known to clients, a weak key can be tested offline and then used to forge tokens for other sandbox IDs. Require sufficient key material—preferably a strict encoding of at least 32 random bytes—and update the diagnostic/config guidance accordingly.

Comment on lines +95 to +98
assert_eq!(
metadata.access_token_hash,
hash_access_token(token.expose())
);

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.

[test · medium]
This assertion computes the expected value with the same helper used by set_access_token, so it will still pass if the digest algorithm, byte encoding, or hex representation changes incorrectly. Since envd authentication depends on this wire-format contract, add a fixed SHA-512 test vector (for example, assert the exact lowercase digest of "abc") and retain this assertion only to verify token plumbing. That will catch compatibility-breaking hash changes before all secure envd requests are rejected.

Suggestion:

Suggested change
assert_eq!(
metadata.access_token_hash,
hash_access_token(token.expose())
);
assert_eq!(
hash_access_token("abc"),
concat!(
"ddaf35a193617abacc417349ae204131",
"12e6fa4e89a97ea20a9eeee64b55d39a",
"2192992a274fc1a836ba3c23a3feebbd",
"454d4423643ce80e2a9ac94fa54ca49f"
)
);
assert_eq!(
metadata.access_token_hash,
hash_access_token(token.expose())
);

Comment on lines +487 to +491
Self::from_snapshot_config_with_override(
snapshot.clone(),
SandboxId::new(),
snapshot.common.envd_access_token.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.

[security · high]
This creates a new runtime identity but reuses the source sandbox's credential. SandboxAccessTokenGenerator derives credentials from SandboxId, so the resulting sandbox has a token that does not belong to its new ID; repeated restores also share the same bearer token. This can break ID-based validation and allows one restored sandbox's credential to authenticate to its siblings. Make callers provide the token for the new ID (or accept a generator/spec), rather than copying it from the source snapshot; if this API is intended to resume the same identity, preserve the source ID instead.

Suggestion:

Suggested change
Self::from_snapshot_config_with_override(
snapshot.clone(),
SandboxId::new(),
snapshot.common.envd_access_token.clone(),
)
let id = SandboxId::new();
Self::from_snapshot_config_with_override(snapshot.clone(), id, None)

let uri: Uri = addr.parse().context("Invalid URI")?;
let access_token = access_token
.map(|token| {
let mut token = http::HeaderValue::from_str(token)?;

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]
This does not compile because from_str is provided by the std::str::FromStr trait, which is not imported in this module. Import that trait or parse the value directly.

Suggestion:

Suggested change
let mut token = http::HeaderValue::from_str(token)?;
let mut token = token.parse::<http::HeaderValue>()?;

@yingdi-shan
yingdi-shan self-requested a review August 13, 2026 12:53

@yingdi-shan yingdi-shan left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

As we discussed before, I think we should avoid adding a shared seed in this PR. It only serves as a workaround for VM migration, which isn't on the near-term roadmap. Removing it here would simplify deployment.

@LSX-s-Software

Copy link
Copy Markdown
Member Author

The seed is already OPTIONAL. We are not requiring users to configure it or enabling cross-node migration in this PR.

This only documents an optional way for operators to prepare for future multi-node sandbox recovery, so they won’t need to reconfigure the entire cluster later. In practice, configuring the seed takes only a few commands, which is negligible compared with the setup already required for a multi-node deployment.

Users who do not need this can simply leave the seed unset and use the automatically managed node-local seed.

@yingdi-shan

Copy link
Copy Markdown
Collaborator

The seed is already OPTIONAL. We are not requiring users to configure it or enabling cross-node migration in this PR.

This only documents an optional way for operators to prepare for future multi-node sandbox recovery, so they won’t need to reconfigure the entire cluster later. In practice, configuring the seed takes only a few commands, which is negligible compared with the setup already required for a multi-node deployment.

Users who do not need this can simply leave the seed unset and use the automatically managed node-local seed.

The docs does not say that. Maybe you should update the docs.

impl Client {
pub fn files(&self, sandbox_id: &str) -> Result<EnvdFilesClient> {
EnvdFilesClient::new(&self.base, &self.api_key, sandbox_id)
let sandbox = self.get_sandbox(sandbox_id)?;

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
get_sandbox uses the synchronous ureq client, so this newly added call performs a blocking network request inside upload/download's async run_async task. A slow or unreachable control-plane request can block the Tokio runtime for the ureq timeout (up to 120 seconds), delaying unrelated async work. Resolve the token before entering the async runtime, or provide/use an async sandbox-detail request instead.

Suggestion:

Suggested change
let sandbox = self.get_sandbox(sandbox_id)?;
let sandbox = self.get_sandbox_async(sandbox_id).await?;

let rest: Vec<String> = cmd_iter.collect();

let transport = client.transport(&args.sandbox_id)?;
let sandbox = client.get_sandbox(&args.sandbox_id)?;

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
get_sandbox performs a synchronous ureq network request, but this function is running inside Tokio's async runtime. A slow or stalled API response can block the runtime thread for the client's timeout (up to 120 seconds), delaying the subsequent envd stream and any other runtime work. Fetch the sandbox detail before entering run_async, or execute this blocking request via spawn_blocking/an async HTTP client.

.header("Connect-Protocol-Version", "1")
.header("Connect-Protocol-Version", "1");
match &self.envd_access_token {
Some(token) => builder.header("X-Access-Token", token),

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 access token is inserted from a String, so the resulting HeaderValue is not marked sensitive (unlike the multipart/files client above). If a Connect request or its headers is formatted with Debug during error handling or tracing, this credential can be exposed. Store a pre-built HeaderValue with set_sensitive(true) (validating it in new) and pass that value here.

Suggestion:

Suggested change
Some(token) => builder.header("X-Access-Token", token),
Some(token) => builder.header("X-Access-Token", token),

Comment thread src/api/proxy.rs
const TARGET_PORT_HEADER: &str = "x-agentenv-target-port";
/// E2B-compatible alias for the target port header.
const E2B_TARGET_PORT_HEADER: &str = "e2b-sandbox-port";
const ENVD_ACCESS_TOKEN_HEADER: &str = "x-access-token";

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
x-access-token is left in the client headers and sanitize_request_headers does not remove it, so this credential is forwarded to every proxy target, not only envd's control-plane port. The added test at the arbitrary upstream_addr.port() confirms this behavior. A caller can attach the sandbox's envd token while selecting another sandbox service port, disclosing the credential to that service (and its logs/application). Strip this header for non-control-plane targets (while retaining it only for the authenticated envd control-plane request).

Suggestion:

Suggested change
const ENVD_ACCESS_TOKEN_HEADER: &str = "x-access-token";
const ENVD_ACCESS_TOKEN_HEADER: &str = "x-access-token";

Comment thread src/sandbox/access.rs
Comment on lines +61 to +68
if config.sandbox.access_token_hash_seed.is_none()
&& config.cluster.scheduler_endpoint.is_some()
{
warn!(
path = %managed_seed_path.display(),
"using a node-local managed envd access-token seed; configure AENV_SANDBOX_ACCESS_TOKEN_HASH_SEED with the same value on every node before enabling cross-node sandbox recovery"
);
}

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
When no explicit seed is configured, this intentionally creates/uses a node-local seed even if scheduler_endpoint is set; the warning does not prevent startup. Consequently, two nodes can derive different bearer tokens for the same secure sandbox, so cross-node recovery/proxying fails after placement or restart. If cross-node operation is supported in this mode, require a shared configured seed (or shared secret storage) at startup instead of allowing an informational warning.

use crate::types::SandboxId;

const EMPTY_ACCESS_TOKEN: &str = "";
const RESERVED_FIELDS: [&str; 4] = ["instanceID", "envID", "address", "accessTokenHash"];

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.

maintainability · low
This reserved-key list duplicates the serialized field names declared above. Adding or renaming an MMDS runtime field requires remembering to update this array; otherwise with_extra can expose a newly reserved field to untrusted opaque metadata. Consider centralizing the canonical field-name definition or adding a test that enumerates every runtime field against the filter.

Comment on lines +46 to +49
pub(crate) fn with_access_token(mut self, token: Option<&EnvdAccessToken>) -> Self {
self.set_access_token(token);
self
}

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
The runtime token is not guaranteed to be reflected in the MMDS payload at the point it is sent. mmds_metadata() in sandbox.rs returns the stored metadata unchanged and, when it is absent, creates MmdsMetadata::new(...) with the empty-token hash; it never calls set_access_token using common.envd_access_token. Consequently, any resume/configuration path with a runtime token but missing or stale metadata sends an empty/stale accessTokenHash, while envd requires the generated token. Please synchronize the metadata with the runtime token immediately before each set_mmds (or make mmds_metadata() apply common.envd_access_token).

Suggestion:

Suggested change
pub(crate) fn with_access_token(mut self, token: Option<&EnvdAccessToken>) -> Self {
self.set_access_token(token);
self
}
pub(crate) fn with_access_token(mut self, token: Option<&EnvdAccessToken>) -> Self {
self.set_access_token(token);
self
}

Comment on lines +487 to +491
Self::from_snapshot_config_with_override(
snapshot.clone(),
SandboxId::new(),
snapshot.common.envd_access_token.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.

security · high
SandboxAccessTokenGenerator binds each token to the sandbox ID, but this default restoration path generates a new ID while copying the source snapshot's token. When a secure snapshot is passed here (for example, an in-memory snapshot config), EnvD will receive credentials generated for the original sandbox and MMDS will advertise the new sandbox's token hash, so authenticated requests fail; if token validation is ID-independent at any boundary, this also enables credential reuse. Derive/pass a token for id (or preserve the source ID) instead of inheriting the source token when assigning a new identity.

Comment thread tests/common/mod.rs
Comment on lines +39 to +42
std::env::set_var(
"AENV_SANDBOX_ACCESS_TOKEN_HASH_SEED",
"integration-test-seed",
);

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 · medium
This mutates the process-wide environment from an async test helper that can be called concurrently by multiple integration tests. Besides leaking the test seed into all tests in the process (including tests that exercise missing/invalid configuration), environment mutation is not a generally thread-safe way to coordinate per-test configuration. Set this once in test-process initialization (before any test threads start), or inject the seed through the test configuration instead of changing the global environment here.

let uri: Uri = addr.parse().context("Invalid URI")?;
let access_token = access_token
.map(|token| {
let mut token = http::HeaderValue::from_str(token)?;

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
This does not compile because from_str is provided by the std::str::FromStr trait, which is not imported in this module. Import that trait or avoid the required import by parsing the string explicitly.

Suggestion:

Suggested change
let mut token = http::HeaderValue::from_str(token)?;
let mut token = token.parse::<http::HeaderValue>()?;

Comment thread docs/src/deployment/docker-compose.md Outdated
Comment thread docs/src/deployment/docker-compose.md Outdated
Comment thread docs/src/deployment/static-multi-node.md Outdated
@LSX-s-Software
LSX-s-Software force-pushed the feat/envd-access-token branch from 0a238fb to 30340ae Compare August 13, 2026 15:24
Comment on lines +313 to +314
let sandbox = self.get_sandbox(sandbox_id)?;
EnvdFilesClient::new(

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
get_sandbox performs a synchronous ureq network request, but files() is called from the async upload/download paths. This newly introduced call therefore blocks the Tokio runtime thread for up to the client's connect/request timeout, potentially stalling other async work (and is especially problematic on a single-thread runtime). Fetch the sandbox detail before entering run_async, or execute this synchronous lookup via spawn_blocking/provide an async client method.

Comment on lines +814 to +815
async fn envd_ready_probe(transport: Arc<Transport>) -> Result<bool> {
match tokio::time::timeout(RECONNECT_PROBE_TIMEOUT, transport.ready()).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.

performance · low
The probe only borrows Transport, so taking an Arc by value forces an unnecessary atomic refcount increment on every watchdog iteration (Arc::clone(&transport)). Accept &Transport instead and call it as envd_ready_probe(&transport); the borrow remains valid for the awaited call because the watchdog owns the Arc.

Suggestion:

Suggested change
async fn envd_ready_probe(transport: Arc<Transport>) -> Result<bool> {
match tokio::time::timeout(RECONNECT_PROBE_TIMEOUT, transport.ready()).await {
async fn envd_ready_probe(transport: &Transport) -> Result<bool> {
match tokio::time::timeout(RECONNECT_PROBE_TIMEOUT, transport.ready()).await {

let rest: Vec<String> = cmd_iter.collect();

let transport = client.transport(&args.sandbox_id)?;
let sandbox = client.get_sandbox(&args.sandbox_id)?;

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
get_sandbox performs a synchronous ureq network request, but this call is now made inside run_async before the first .await. That blocks the Tokio executor for the request timeout (up to the configured 120 seconds), and would stall any other work sharing this runtime. Fetch the sandbox/token before entering block_on, use an async HTTP client, or move this blocking operation to tokio::task::spawn_blocking.

Suggestion:

Suggested change
let sandbox = client.get_sandbox(&args.sandbox_id)?;
let sandbox = tokio::task::spawn_blocking({
let client = client.clone();
let sandbox_id = args.sandbox_id.clone();
move || client.get_sandbox(&sandbox_id)
})
.await??;

.header("Connect-Protocol-Version", "1")
.header("Connect-Protocol-Version", "1");
match &self.envd_access_token {
Some(token) => builder.header("X-Access-Token", token),

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 access token is inserted from a plain String, so the resulting HeaderValue is not marked sensitive. Any request/header debug output (including middleware diagnostics) may therefore expose this credential. Parse and validate the token in Transport::new, call set_sensitive(true), and store the resulting HeaderValue for reuse, as the other envd clients do.

Suggestion:

Suggested change
Some(token) => builder.header("X-Access-Token", token),
Some(token) => builder.header("X-Access-Token", token), // where token is a prevalidated HeaderValue with set_sensitive(true)

Comment thread src/api/proxy.rs
const TARGET_PORT_HEADER: &str = "x-agentenv-target-port";
/// E2B-compatible alias for the target port header.
const E2B_TARGET_PORT_HEADER: &str = "e2b-sandbox-port";
const ENVD_ACCESS_TOKEN_HEADER: &str = "x-access-token";

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 credential is accepted from every proxy request, but sanitize_request_headers and the WebSocket path do not remove it, so it is forwarded to any client-selected target port (the added test explicitly verifies forwarding to an arbitrary upstream). A caller can therefore disclose the sandbox's envd token to an application service or other untrusted workload by including this header, and the token is also exposed on non-control-plane routes where no authorization check is performed. Strip this header by default and only forward it when the upstream is the trusted envd control-plane endpoint (or otherwise ensure it cannot be used for arbitrary target ports).

Suggestion:

Suggested change
const ENVD_ACCESS_TOKEN_HEADER: &str = "x-access-token";
const ENVD_ACCESS_TOKEN_HEADER: &str = "x-access-token";

Comment thread src/sandbox/access.rs
Comment on lines +90 to +96
fn validate_explicit_seed(seed: &str) -> Result<&str> {
let seed = seed.trim();
if seed.is_empty() {
bail!("[sandbox].access_token_hash_seed must be non-empty when configured");
}
Ok(seed)
}

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 explicit seed path accepts any non-empty string, including short or predictable values such as the documented "replace-with-a-secret" placeholder or a simple test-like value. This seed is the sole HMAC key for every secure sandbox token, so an operator who supplies low-entropy configuration can brute-force the key and forge access to all sandboxes. Enforce a minimum cryptographic strength/length (for example, exactly 32 random bytes represented as 64 lowercase hex characters, matching the managed seed), or reject weak values with an explicit diagnostic.

Suggestion:

Suggested change
fn validate_explicit_seed(seed: &str) -> Result<&str> {
let seed = seed.trim();
if seed.is_empty() {
bail!("[sandbox].access_token_hash_seed must be non-empty when configured");
}
Ok(seed)
}
fn validate_explicit_seed(seed: &str) -> Result<&str> {
let seed = seed.trim();
if !is_valid_managed_seed(seed) {
bail!(
"[sandbox].access_token_hash_seed must contain exactly {SEED_HEX_LEN} lowercase hexadecimal characters"
);
}
Ok(seed)
}

Comment thread src/sandbox/access.rs
Comment on lines +220 to +228
fs::create_dir_all(parent)
.with_context(|| format!("create managed secret directory {}", parent.display()))?;
validate_managed_seed_directory_identity(parent).with_context(|| {
format!(
"validate managed secret directory ownership {}",
parent.display()
)
})?;
set_permissions(parent, 0o700)?;

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
When this directory is newly created, create_dir_all applies the process umask/default mode first and only then changes it to 0700. During that interval another local user/process with access to AENV_HOME can potentially traverse the directory; although short, the seed temporary file is created later and the HMAC key protects every sandbox token. Create the directory with restrictive permissions atomically (or otherwise ensure the parent hierarchy is private before exposing the seed path), then validate its identity and mode.

Suggestion:

Suggested change
fs::create_dir_all(parent)
.with_context(|| format!("create managed secret directory {}", parent.display()))?;
validate_managed_seed_directory_identity(parent).with_context(|| {
format!(
"validate managed secret directory ownership {}",
parent.display()
)
})?;
set_permissions(parent, 0o700)?;
fs::create_dir_all(parent)
.with_context(|| format!("create managed secret directory {}", parent.display()))?;
set_permissions(parent, 0o700)?;
validate_managed_seed_directory_identity(parent).with_context(|| {
format!(
"validate managed secret directory ownership {}",
parent.display()
)
})?;

Comment on lines +487 to +491
Self::from_snapshot_config_with_override(
snapshot.clone(),
SandboxId::new(),
snapshot.common.envd_access_token.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.

security · high
This assigns a new sandbox identity while reusing the source sandbox's credential. Because access tokens are derived per SandboxId, an in-memory secure snapshot resumed through this public path will create a different sandbox that is still controllable with the source token; any identity-based token validation will also derive a different token and disagree with envd. Do not carry a token across an identity change. Require the caller to provide a token generated for the new ID (as the orchestrator/factory path does), or preserve the original identity when this is an in-place resume.

Suggestion:

Suggested change
Self::from_snapshot_config_with_override(
snapshot.clone(),
SandboxId::new(),
snapshot.common.envd_access_token.clone(),
)
let id = SandboxId::new();
Self::from_snapshot_config_with_override(snapshot.clone(), id, None)

Comment thread tests/common/mod.rs
Comment on lines +39 to +42
std::env::set_var(
"AENV_SANDBOX_ACCESS_TOKEN_HASH_SEED",
"integration-test-seed",
);

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 · medium
setup_runtime_only() runs from #[tokio::test] after the multithreaded runtime has started, and multiple integration tests can invoke it concurrently. Mutating the process environment at that point is not safe on Unix when any other thread may read the environment (this is why set_var is unsafe in Rust 2024), and it can also overwrite a seed explicitly supplied by the test environment. Please inject this value through a test configuration/init API, or arrange for the test runner to set it before the process becomes multithreaded rather than changing it here.

Suggestion:

Suggested change
std::env::set_var(
"AENV_SANDBOX_ACCESS_TOKEN_HASH_SEED",
"integration-test-seed",
);
// Supply the integration-test seed through ConfigManager test initialization
// instead of mutating the process environment from the async test runtime.

let uri: Uri = addr.parse().context("Invalid URI")?;
let access_token = access_token
.map(|token| {
let mut token = http::HeaderValue::from_str(token)?;

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
This does not compile because from_str is provided by std::str::FromStr, which is not imported in this module. Import the trait or use str::parse so the token validation remains fallible.

Suggestion:

Suggested change
let mut token = http::HeaderValue::from_str(token)?;
let mut token = token.parse::<http::HeaderValue>()?;

@yingdi-shan
yingdi-shan merged commit 1956434 into kvcache-ai:main Aug 14, 2026
10 checks passed
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