Skip to content

Commit 9730e81

Browse files
authored
feat(e2b): support mounting pre-existing volumes at sandbox creation (#3581)
Closes #3155. E2B offers persistent volumes that exist independently of sandbox lifecycles. This PR wires attach-at-create through the E2B backend so users no longer need TAR persist/hydrate on every turn for cross-sandbox persistence. ## What is included - New `E2bVolumeMount` POJO (existing volume name + absolute in-sandbox path, validation + normalization) - `E2bSandboxClientOptions.volumeMounts` (fluent adders, distinct-path check) and `E2bFilesystemSpec.volumeMount/volumeMounts` - `POST /sandboxes` serializes `volumeMounts: [{name, path}]` (omitted when empty); platform 400s propagate with the response body - Mounts threaded through `E2bSandboxClient.create` into `E2bSandboxState` and across all three creation paths (new, connect-fallback recreate, native-snapshot restore) - `workspaceOnVolume` detection (`root.equals(mount) || root.startsWith(mount + "/")`): TAR persist/hydrate become no-ops, destroy skips `rm -rf`; nested mounts get TAR `--exclude` args - Hardening found via live testing: fail fast on non-absolute `workspaceRoot`, shell-quote TAR excludes, run workspace `mkdir` from `/` - `E2bSandboxState` JSON stays backward compatible; empty `E2bCloudBucketMountStrategy` placeholder removed - Docs: E2B volumes section in `filesystem.md` (EN/ZH)
1 parent ed80732 commit 9730e81

14 files changed

Lines changed: 1582 additions & 18 deletions

File tree

‎agentscope-extensions/agentscope-extensions-sandbox/agentscope-extensions-sandbox-e2b/src/main/java/io/agentscope/extensions/sandbox/e2b/E2bFilesystemSpec.java‎

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@
2121
import io.agentscope.harness.agent.sandbox.WorkspaceSpec;
2222
import io.agentscope.harness.agent.sandbox.snapshot.NoopSnapshotSpec;
2323
import io.agentscope.harness.agent.sandbox.snapshot.SandboxSnapshotSpec;
24+
import java.util.List;
2425
import java.util.function.Supplier;
2526

2627
/** {@link SandboxFilesystemSpec} for E2B cloud sandboxes. */
@@ -110,6 +111,30 @@ public E2bFilesystemSpec codec(E2bCodec codec) {
110111
return this;
111112
}
112113

114+
public E2bFilesystemSpec volumeMount(String name, String path) {
115+
options.addVolumeMount(name, path);
116+
return this;
117+
}
118+
119+
public E2bFilesystemSpec volumeMount(E2bVolumeMount mount) {
120+
options.addVolumeMount(mount);
121+
return this;
122+
}
123+
124+
/**
125+
* Replaces all configured volume mounts.
126+
*
127+
* <p>A null or empty list clears mounts on this spec, but note that {@link E2bSandboxClient}
128+
* merging treats an empty call-level list as "not set" (same convention as AgentRun OSS
129+
* mounts), so clearing only takes effect when these options are also the client defaults.
130+
*
131+
* @param mounts replacement mounts (null or empty clears previously added ones)
132+
*/
133+
public E2bFilesystemSpec volumeMounts(List<E2bVolumeMount> mounts) {
134+
options.setVolumeMounts(mounts);
135+
return this;
136+
}
137+
113138
public E2bFilesystemSpec connectTimeoutSeconds(int connectTimeoutSeconds) {
114139
options.setConnectTimeoutSeconds(connectTimeoutSeconds);
115140
return this;

‎agentscope-extensions/agentscope-extensions-sandbox/agentscope-extensions-sandbox-e2b/src/main/java/io/agentscope/extensions/sandbox/e2b/E2bPlatformHttp.java‎

Lines changed: 33 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -61,18 +61,50 @@ final class E2bPlatformHttp {
6161
}
6262

6363
JsonNode createSandbox(String templateId, int timeoutSeconds) throws IOException {
64-
return createSandbox(templateId, timeoutSeconds, Map.of());
64+
return createSandbox(templateId, timeoutSeconds, null, null);
6565
}
6666

6767
JsonNode createSandbox(String templateId, int timeoutSeconds, Map<String, String> envVars)
6868
throws IOException {
69+
return createSandbox(templateId, timeoutSeconds, null, envVars);
70+
}
71+
72+
/**
73+
* Creates a sandbox, mounting pre-existing volumes when {@code mounts} is non-empty.
74+
*
75+
* <p>Mounts are serialized as {@code volumeMounts: [{name, path}]} per the platform REST API.
76+
* The field is omitted when empty. Platform 400s (no volume beta access, unknown volume name)
77+
* propagate with the response body via {@link #postJson}.
78+
*/
79+
JsonNode createSandbox(
80+
String templateId,
81+
int timeoutSeconds,
82+
List<E2bVolumeMount> mounts,
83+
Map<String, String> envVars)
84+
throws IOException {
6985
ObjectNode body = json.createObjectNode();
7086
body.put("templateID", templateId);
7187
body.put("timeout", timeoutSeconds);
7288
if (envVars != null && !envVars.isEmpty()) {
7389
ObjectNode envs = body.putObject("envVars");
7490
envVars.forEach(envs::put);
7591
}
92+
if (mounts != null && !mounts.isEmpty()) {
93+
var arr = body.putArray("volumeMounts");
94+
int count = 0;
95+
for (E2bVolumeMount m : mounts) {
96+
if (m == null) {
97+
continue;
98+
}
99+
var o = arr.addObject();
100+
o.put("name", m.getName());
101+
o.put("path", m.getPath());
102+
count++;
103+
}
104+
if (count == 0) {
105+
body.remove("volumeMounts");
106+
}
107+
}
76108
String url = trimSlash(opt.getApiBaseUrl()) + "/sandboxes";
77109
return E2bRetry.withRetries(
78110
opt.getMaxRetries(), () -> postJson(url, body, /* apiKey */ true));

‎agentscope-extensions/agentscope-extensions-sandbox/agentscope-extensions-sandbox-e2b/src/main/java/io/agentscope/extensions/sandbox/e2b/E2bSandbox.java‎

Lines changed: 78 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@
2727
import java.io.ByteArrayInputStream;
2828
import java.io.IOException;
2929
import java.io.InputStream;
30+
import java.util.ArrayList;
3031
import java.util.LinkedHashMap;
3132
import java.util.List;
3233
import java.util.Map;
@@ -142,9 +143,26 @@ protected ExecResult doExec(RuntimeContext runtimeContext, String command, int t
142143
return envd().runShell(e2bState, getWorkspaceRoot(), command, timeoutSeconds);
143144
}
144145

146+
@Override
147+
protected boolean probeWorkspaceRootForPreservedResume() {
148+
if (e2bState.isWorkspaceOnVolume()) {
149+
// Files live on a persistent volume — the directory is durable across sandbox
150+
// recreations, so treat it as preserved without probing.
151+
return true;
152+
}
153+
return super.probeWorkspaceRootForPreservedResume();
154+
}
155+
145156
@Override
146157
protected InputStream doPersistWorkspace() throws Exception {
147158
if (e2bState.getPersistenceMode() == E2bPersistenceMode.NATIVE_SNAPSHOT) {
159+
// Native snapshots preserve the software environment (installed deps, system state);
160+
// volume bytes stay durable via the volume itself and mounts are re-attached on
161+
// restore. Keep the snapshot call even when the workspace sits on a volume.
162+
// Verified live
163+
// (E2bVolumeMountLiveTest#nestedMountContentSurvivesNativeSnapshotRestoreUnchanged):
164+
// platform snapshots do not capture mounted volume bytes, so a nested mount under a
165+
// non-volume workspace root is unaffected by snapshot restore.
148166
JsonNode snap = platform.createSandboxSnapshot(e2bState.getSandboxId(), snapshotName());
149167
String id = snap.path("snapshotID").asText("");
150168
if (id.isBlank()) {
@@ -156,6 +174,13 @@ protected InputStream doPersistWorkspace() throws Exception {
156174
e2bState.getSnapshotIds().add(id);
157175
return new ByteArrayInputStream(E2bSnapshotRefs.encodeSnapshotId(id));
158176
}
177+
if (e2bState.isWorkspaceOnVolume()) {
178+
// Workspace bytes are durable via the volume — nothing to archive. The empty stream
179+
// doubles as the no-op marker read back by doHydrateWorkspace: a session flipping
180+
// between volume-backed and plain persistence replaces a real archive with this
181+
// empty one, meaning "hydrate nothing, the volume already holds the files".
182+
return InputStream.nullInputStream();
183+
}
159184
String root = e2bState.getWorkspaceRoot();
160185
String tarPath = newTempTarPath();
161186
String script = buildTarCommand(true, tarPath, root);
@@ -183,6 +208,12 @@ protected Optional<InputStream> openSnapshotArchive(SandboxSnapshot snapshot) th
183208
@Override
184209
protected void doHydrateWorkspace(InputStream archive) throws Exception {
185210
byte[] all = archive.readAllBytes();
211+
if (all.length == 0) {
212+
// Volume-backed workspaces persist nothing to hydrate (see doPersistWorkspace); the
213+
// empty archive is an intentional no-op marker, not an error, and must not overwrite
214+
// what a previous non-volume archive contained.
215+
return;
216+
}
186217
String nativeId = E2bSnapshotRefs.decodeSnapshotIdIfPresent(all);
187218
if (nativeId != null && !nativeId.isBlank()) {
188219
restoreSandboxFromSnapshotTemplate(nativeId, all);
@@ -201,24 +232,23 @@ protected void doHydrateWorkspace(InputStream archive) throws Exception {
201232

202233
@Override
203234
protected void doSetupWorkspace() throws Exception {
235+
// mkdir's own cwd must already exist: a workspace root on a freshly mounted volume
236+
// (e.g. /mnt/data/ws) typically does not exist yet, so run from / instead. The
237+
// mkdir target is absolute and unaffected by the cwd.
204238
envd().runShell(
205239
e2bState,
206-
getWorkspaceRoot(),
240+
"/",
207241
"mkdir -p " + shellSingleQuote(e2bState.getWorkspaceRoot()),
208242
30);
209243
}
210244

211245
@Override
212246
protected void doDestroyWorkspace() throws Exception {
213-
try {
214-
envd().runShell(
215-
e2bState,
216-
getWorkspaceRoot(),
217-
"rm -rf " + shellSingleQuote(e2bState.getWorkspaceRoot()),
218-
30);
219-
} catch (Exception e) {
220-
log.debug("[sandbox-e2b] destroy workspace best-effort: {}", e.getMessage());
221-
}
247+
// No-op by contract. An E2B workspace's lifetime is bounded by the sandbox itself, so
248+
// destruction is delegated to sandbox teardown; running "rm -rf" here beforehand would
249+
// stretch a deletion through any nested volume mount into shared, persistent data
250+
// without ever having an observable effect (the disk vanishes with the sandbox).
251+
log.debug("[sandbox-e2b] workspace destruction is a no-op; sandbox teardown releases it");
222252
}
223253

224254
@Override
@@ -314,6 +344,7 @@ private JsonNode createSandboxForRestore() throws IOException {
314344
platform.createSandbox(
315345
e2bState.getTemplateId(),
316346
opt.getSandboxTimeoutSeconds(),
347+
e2bState.getVolumeMounts(),
317348
resolveEnvVars());
318349
markWorkspaceUnready();
319350
return created;
@@ -336,6 +367,7 @@ private JsonNode createSandboxForRestore() throws IOException {
336367
platform.createSandbox(
337368
e2bState.getTemplateId(),
338369
opt.getSandboxTimeoutSeconds(),
370+
e2bState.getVolumeMounts(),
339371
resolveEnvVars());
340372
markWorkspaceUnready();
341373
return created;
@@ -349,7 +381,10 @@ private JsonNode createSandboxForRestore() throws IOException {
349381
nativeSnapshotPlan = NativeSnapshotPlan.DEFAULT;
350382
JsonNode created =
351383
platform.createSandbox(
352-
e2bState.getTemplateId(), opt.getSandboxTimeoutSeconds(), resolveEnvVars());
384+
e2bState.getTemplateId(),
385+
opt.getSandboxTimeoutSeconds(),
386+
e2bState.getVolumeMounts(),
387+
resolveEnvVars());
353388
markWorkspaceUnready();
354389
return created;
355390
}
@@ -370,7 +405,10 @@ private void markWorkspaceUnready() {
370405
private JsonNode createFromSnapshot(String snapshotId, byte[] archive) throws IOException {
371406
JsonNode created =
372407
platform.createSandbox(
373-
snapshotId, opt.getSandboxTimeoutSeconds(), resolveEnvVars());
408+
snapshotId,
409+
opt.getSandboxTimeoutSeconds(),
410+
e2bState.getVolumeMounts(),
411+
resolveEnvVars());
374412
nativeSnapshotPlan = NativeSnapshotPlan.restored(snapshotId, archive);
375413
return created;
376414
}
@@ -493,6 +531,23 @@ private static String snapshotName() {
493531
return "agentscope-" + shortId + "-" + System.currentTimeMillis();
494532
}
495533

534+
/**
535+
* {@code tar --exclude} args for volume mounts nested strictly beneath the workspace root.
536+
* A mount covering the root needs no exclusion (persistence is a no-op in that case).
537+
*/
538+
private List<String> volumeTarExcludeArgs() {
539+
List<E2bVolumeMount> mounts = e2bState.getVolumeMounts();
540+
if (mounts.isEmpty()) {
541+
return List.of();
542+
}
543+
List<String> paths = new ArrayList<>(mounts.size());
544+
for (E2bVolumeMount m : mounts) {
545+
paths.add(m.getPath());
546+
}
547+
return WorkspaceMountSupport.tarExcludeArgsForAbsolutePaths(
548+
e2bState.getWorkspaceRoot(), paths);
549+
}
550+
496551
private E2bEnvdProcessClient envd() throws Exception {
497552
E2bEnvdProcessClient c = envd;
498553
if (c == null) {
@@ -522,7 +577,16 @@ private String buildTarCommand(boolean create, String tarPath, String root) {
522577
StringBuilder script = new StringBuilder("tar ");
523578
for (String ex :
524579
WorkspaceMountSupport.tarExcludeArgsForBindMounts(e2bState.getWorkspaceSpec())) {
525-
script.append(ex).append(' ');
580+
// Quote: exclude args derive from user-configured paths and may contain spaces.
581+
script.append(shellSingleQuote(ex)).append(' ');
582+
}
583+
// Apply volume-mount excludes on both branches. The create branch must not re-duplicate
584+
// volume bytes into the archive; the extract branch must not let archive entries that
585+
// predate the current mount configuration (e.g. a NATIVE_SNAPSHOT archive written before
586+
// the volume was mounted under the workspace root) write through the mount point into
587+
// the shared volume.
588+
for (String ex : volumeTarExcludeArgs()) {
589+
script.append(shellSingleQuote(ex)).append(' ');
526590
}
527591
script.append(create ? "-cf " : "-xf ")
528592
.append(tarPath)
@@ -551,10 +615,7 @@ private String workspaceTransferPath(String path) {
551615
if (root == null || root.isBlank() || !root.startsWith("/")) {
552616
return null;
553617
}
554-
String normalizedRoot = root.replace('\\', '/');
555-
while (normalizedRoot.length() > 1 && normalizedRoot.endsWith("/")) {
556-
normalizedRoot = normalizedRoot.substring(0, normalizedRoot.length() - 1);
557-
}
618+
String normalizedRoot = WorkspaceMountSupport.normalizeVirtualPath(root);
558619
String rootPrefix = "/".equals(normalizedRoot) ? "/" : normalizedRoot + "/";
559620
if (!path.startsWith(rootPrefix)) {
560621
return null;

‎agentscope-extensions/agentscope-extensions-sandbox/agentscope-extensions-sandbox-e2b/src/main/java/io/agentscope/extensions/sandbox/e2b/E2bSandboxClient.java‎

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,7 @@ public Sandbox create(
5858
E2bSandboxClientOptions options) {
5959
String sessionId = UUID.randomUUID().toString();
6060
E2bSandboxClientOptions merged = merge(options);
61+
requireAbsoluteWorkspaceRoot(merged.getWorkspaceRoot());
6162

6263
E2bSandboxState state = new E2bSandboxState();
6364
state.setSessionId(sessionId);
@@ -70,6 +71,8 @@ public Sandbox create(
7071
state.setCodec(merged.getCodec());
7172
state.setSandboxDomain(merged.getDomain());
7273
state.setEnvdSandboxUrl(merged.getEnvdSandboxUrl());
74+
state.setVolumeMounts(merged.getVolumeMounts());
75+
state.setWorkspaceOnVolume(isWorkspaceOnVolume(merged));
7376

7477
if (snapshotSpec != null) {
7578
state.setSnapshot(snapshotSpec.build(sessionId));
@@ -85,6 +88,7 @@ public Sandbox resume(SandboxState state) {
8588
throw new IllegalArgumentException(
8689
"Expected E2bSandboxState but got: " + state.getClass().getName());
8790
}
91+
requireAbsoluteWorkspaceRoot(e2b.getWorkspaceRoot());
8892
E2bSandboxClientOptions resumed = copy(defaultOptions);
8993
resumed.setCodec(e2b.getCodec());
9094
resumed.setEnvdSandboxUrl(e2b.getEnvdSandboxUrl());
@@ -152,6 +156,11 @@ private E2bSandboxClientOptions merge(E2bSandboxClientOptions call) {
152156
if (call.getHttpClient() != null) {
153157
o.setHttpClient(call.getHttpClient());
154158
}
159+
// Null or empty call mounts keep the defaults (same convention as AgentRun OSS mounts);
160+
// there is intentionally no "clear all mounts" via merge.
161+
if (call.getVolumeMounts() != null && !call.getVolumeMounts().isEmpty()) {
162+
o.setVolumeMounts(call.getVolumeMounts());
163+
}
155164
o.setConnectTimeoutSeconds(call.getConnectTimeoutSeconds());
156165
o.setReadTimeoutSeconds(call.getReadTimeoutSeconds());
157166
o.setMaxRetries(call.getMaxRetries());
@@ -166,6 +175,41 @@ private E2bSandboxClientOptions merge(E2bSandboxClientOptions call) {
166175
return o;
167176
}
168177

178+
/**
179+
* Rejects blank or relative workspace roots fail-fast; every E2B operation assumes an
180+
* absolute in-sandbox path.
181+
*
182+
* @param workspaceRoot workspace root to check
183+
*/
184+
private static void requireAbsoluteWorkspaceRoot(String workspaceRoot) {
185+
if (workspaceRoot == null
186+
|| workspaceRoot.isBlank()
187+
|| !workspaceRoot.strip().startsWith("/")) {
188+
throw new SandboxException.SandboxConfigurationException(
189+
"E2B workspaceRoot must be an absolute in-sandbox path but was: "
190+
+ workspaceRoot);
191+
}
192+
}
193+
194+
/**
195+
* Returns whether the workspace root lives on one of the configured volume mounts.
196+
*
197+
* @param opt merged client options
198+
* @return true when a mount covers the workspace root
199+
*/
200+
private static boolean isWorkspaceOnVolume(E2bSandboxClientOptions opt) {
201+
String root = opt.getWorkspaceRoot();
202+
if (root == null || root.isBlank() || opt.getVolumeMounts() == null) {
203+
return false;
204+
}
205+
for (E2bVolumeMount m : opt.getVolumeMounts()) {
206+
if (m != null && E2bVolumeMount.coversWorkspaceRoot(root, m.getPath())) {
207+
return true;
208+
}
209+
}
210+
return false;
211+
}
212+
169213
private static E2bSandboxClientOptions copy(E2bSandboxClientOptions src) {
170214
E2bSandboxClientOptions o = new E2bSandboxClientOptions();
171215
o.setApiKey(src.getApiKey());
@@ -179,6 +223,7 @@ private static E2bSandboxClientOptions copy(E2bSandboxClientOptions src) {
179223
o.setPersistenceMode(src.getPersistenceMode());
180224
o.setCodec(src.getCodec());
181225
o.setHttpClient(src.getHttpClient());
226+
o.setVolumeMounts(src.getVolumeMounts());
182227
o.setConnectTimeoutSeconds(src.getConnectTimeoutSeconds());
183228
o.setReadTimeoutSeconds(src.getReadTimeoutSeconds());
184229
o.setMaxRetries(src.getMaxRetries());

0 commit comments

Comments
 (0)