Skip to content

Commit 3cca0f5

Browse files
committed
fix(e2b): address review feedback on volume mounts
- Keep E2bSandboxState forward compatible with @JsonIgnoreProperties(ignoreUnknown = true) so states written with mounts load on older builds instead of falling back to fresh create - Fix tarExcludeArgsForAbsolutePaths for workspaceRoot "/" and collapse duplicate slashes, matching E2bVolumeMount normalization - Keep E2bCloudBucketMountStrategy as @deprecated for one transition release instead of removing the public interface outright - Centralize distinct-mount-path validation in E2bVolumeMount and enforce it in E2bSandboxState.setVolumeMounts as well - Add unit tests for the volume-critical branches (persist no-op, empty hydrate, destroy skip), "/" excludes, state validation and unknown-field serde so CI catches regressions - Fix misplaced javadoc on requireAbsoluteWorkspaceRoot and enforce the absolute-root check in resume() too - Require E2B_VOLUME_NAME for live tests instead of a hardcoded default volume - Document read-write/multi-mount/no-locking volume semantics Refs: #3155
1 parent e8c0a56 commit 3cca0f5

11 files changed

Lines changed: 170 additions & 28 deletions

File tree

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

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,7 @@ public Sandbox resume(SandboxState state) {
8888
throw new IllegalArgumentException(
8989
"Expected E2bSandboxState but got: " + state.getClass().getName());
9090
}
91+
requireAbsoluteWorkspaceRoot(e2b.getWorkspaceRoot());
9192
E2bSandboxClientOptions resumed = copy(defaultOptions);
9293
resumed.setCodec(e2b.getCodec());
9394
resumed.setEnvdSandboxUrl(e2b.getEnvdSandboxUrl());
@@ -175,10 +176,10 @@ private E2bSandboxClientOptions merge(E2bSandboxClientOptions call) {
175176
}
176177

177178
/**
178-
* Returns whether the workspace root lives on one of the configured volume mounts.
179+
* Rejects blank or relative workspace roots fail-fast; every E2B operation assumes an
180+
* absolute in-sandbox path.
179181
*
180-
* @param opt merged client options
181-
* @return true when a mount covers the workspace root
182+
* @param workspaceRoot workspace root to check
182183
*/
183184
private static void requireAbsoluteWorkspaceRoot(String workspaceRoot) {
184185
if (workspaceRoot == null
@@ -190,6 +191,12 @@ private static void requireAbsoluteWorkspaceRoot(String workspaceRoot) {
190191
}
191192
}
192193

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+
*/
193200
private static boolean isWorkspaceOnVolume(E2bSandboxClientOptions opt) {
194201
String root = opt.getWorkspaceRoot();
195202
if (root == null || root.isBlank() || opt.getVolumeMounts() == null) {

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

Lines changed: 1 addition & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,6 @@
1717

1818
import io.agentscope.harness.agent.sandbox.SandboxClient;
1919
import io.agentscope.harness.agent.sandbox.SandboxClientOptions;
20-
import io.agentscope.harness.agent.sandbox.SandboxException;
2120
import java.util.ArrayList;
2221
import java.util.LinkedHashMap;
2322
import java.util.List;
@@ -235,7 +234,7 @@ public void setVolumeMounts(List<E2bVolumeMount> volumeMounts) {
235234
checked.add(new E2bVolumeMount(m.getName(), m.getPath()));
236235
}
237236
}
238-
requireDistinctMountPaths(checked);
237+
E2bVolumeMount.requireDistinctMountPaths(checked);
239238
this.volumeMounts = checked;
240239
}
241240

@@ -253,18 +252,6 @@ public E2bSandboxClientOptions addVolumeMount(E2bVolumeMount mount) {
253252
return addVolumeMount(mount.getName(), mount.getPath());
254253
}
255254

256-
private static void requireDistinctMountPaths(List<E2bVolumeMount> mounts) {
257-
for (int i = 0; i < mounts.size(); i++) {
258-
for (int j = i + 1; j < mounts.size(); j++) {
259-
if (mounts.get(i).getPath().equals(mounts.get(j).getPath())) {
260-
throw new SandboxException.SandboxConfigurationException(
261-
"E2B volume mounts must use distinct paths, duplicated: "
262-
+ mounts.get(i).getPath());
263-
}
264-
}
265-
}
266-
}
267-
268255
public int getConnectTimeoutSeconds() {
269256
return connectTimeoutSeconds;
270257
}

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

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,11 +15,13 @@
1515
*/
1616
package io.agentscope.extensions.sandbox.e2b;
1717

18+
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
1819
import io.agentscope.harness.agent.sandbox.SandboxState;
1920
import java.util.ArrayList;
2021
import java.util.List;
2122

2223
/** Serializable state for an E2B-backed sandbox. */
24+
@JsonIgnoreProperties(ignoreUnknown = true)
2325
public class E2bSandboxState extends SandboxState {
2426

2527
private String sandboxId;
@@ -166,6 +168,7 @@ public void setVolumeMounts(List<E2bVolumeMount> volumeMounts) {
166168
checked.add(new E2bVolumeMount(m.getName(), m.getPath()));
167169
}
168170
}
171+
E2bVolumeMount.requireDistinctMountPaths(checked);
169172
this.volumeMounts = checked;
170173
}
171174

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

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
package io.agentscope.extensions.sandbox.e2b;
1717

1818
import io.agentscope.harness.agent.sandbox.SandboxException;
19+
import java.util.List;
1920
import java.util.Objects;
2021

2122
/**
@@ -130,6 +131,31 @@ public static boolean coversWorkspaceRoot(String workspaceRoot, String mountPath
130131
return root.equals(mount) || root.startsWith(mount + "/");
131132
}
132133

134+
/**
135+
* Validates that no two mounts share the same in-sandbox path.
136+
*
137+
* @param mounts mounts to check (null entries are ignored)
138+
* @throws SandboxException.SandboxConfigurationException when a path is duplicated
139+
*/
140+
public static void requireDistinctMountPaths(List<E2bVolumeMount> mounts) {
141+
if (mounts == null) {
142+
return;
143+
}
144+
for (int i = 0; i < mounts.size(); i++) {
145+
if (mounts.get(i) == null) {
146+
continue;
147+
}
148+
for (int j = i + 1; j < mounts.size(); j++) {
149+
if (mounts.get(j) != null
150+
&& mounts.get(i).getPath().equals(mounts.get(j).getPath())) {
151+
throw new SandboxException.SandboxConfigurationException(
152+
"E2B volume mounts must use distinct paths, duplicated: "
153+
+ mounts.get(i).getPath());
154+
}
155+
}
156+
}
157+
}
158+
133159
@Override
134160
public boolean equals(Object o) {
135161
if (this == o) {
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
/*
2+
* Copyright 2024-2026 the original author or authors.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
package io.agentscope.extensions.sandbox.e2b.mounts;
17+
18+
/**
19+
* Placeholder for E2B cloud bucket mount configuration.
20+
*
21+
* @deprecated Superseded by {@link io.agentscope.extensions.sandbox.e2b.E2bVolumeMount}
22+
* attach-at-create mounts. Kept for one transition release so downstream consumers
23+
* referencing this type keep compiling; it carries no behavior and will be removed
24+
* afterwards.
25+
*/
26+
@Deprecated
27+
public interface E2bCloudBucketMountStrategy {
28+
// intentionally empty — extend when wiring E2B volume mounts from Harness workspace specs
29+
}

‎agentscope-extensions/agentscope-extensions-sandbox/agentscope-extensions-sandbox-e2b/src/test/java/io/agentscope/extensions/sandbox/e2b/E2bVolumeMountLiveTest.java‎

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -41,8 +41,8 @@
4141
import org.slf4j.LoggerFactory;
4242

4343
// Live E2B volume tests: run only when E2B_API_KEY is set in the environment.
44-
// Volume name comes from E2B_VOLUME_NAME (defaults to probe-ws-test1, so set it when your
45-
// account uses a different volume).
44+
// E2B_VOLUME_NAME must name an existing volume on the account (no default:
45+
// the platform answers an unknown name with an opaque 400).
4646
// Each test kills every sandbox it creates, even on failure.
4747
@EnabledIfEnvironmentVariable(named = "E2B_API_KEY", matches = ".+")
4848
class E2bVolumeMountLiveTest {
@@ -409,7 +409,11 @@ void nestedMountContentSurvivesNativeSnapshotRestoreUnchanged() throws Exception
409409

410410
private static String volumeName() {
411411
String v = System.getenv("E2B_VOLUME_NAME");
412-
return v != null && !v.isBlank() ? v : "probe-ws-test1";
412+
if (v == null || v.isBlank()) {
413+
throw new IllegalStateException(
414+
"E2B_VOLUME_NAME must be set to an existing E2B volume name for live tests");
415+
}
416+
return v;
413417
}
414418

415419
private static E2bSandboxClientOptions baseOptions() {

‎agentscope-extensions/agentscope-extensions-sandbox/agentscope-extensions-sandbox-e2b/src/test/java/io/agentscope/extensions/sandbox/e2b/E2bVolumeMountTest.java‎

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -305,6 +305,61 @@ void tarCommandQuotesExcludeArgs() throws Exception {
305305
"exclude with spaces must be shell-quoted, got: " + script);
306306
}
307307

308+
@Test
309+
void workspaceOnVolumeBranchesAreNoOpsWithoutEnvd() throws Exception {
310+
E2bSandboxState state = new E2bSandboxState();
311+
state.setWorkspaceSpec(workspaceSpec());
312+
state.setWorkspaceRoot("/mnt/data/ws");
313+
state.setVolumeMounts(List.of(new E2bVolumeMount("vol-a", "/mnt/data")));
314+
state.setWorkspaceOnVolume(true);
315+
// No sandboxId and no reachable envd: any backend call would fail,
316+
// proving the no-op branches short-circuit before touching envd.
317+
E2bSandbox sandbox = new E2bSandbox(state, options());
318+
319+
try (java.io.InputStream archive = sandbox.persistWorkspace()) {
320+
assertEquals(0, archive.readAllBytes().length);
321+
}
322+
sandbox.hydrateWorkspace(java.io.InputStream.nullInputStream());
323+
invokeDoDestroyWorkspace(sandbox);
324+
}
325+
326+
@Test
327+
void stateRejectsDuplicateMountPaths() {
328+
E2bSandboxState state = new E2bSandboxState();
329+
assertThrows(
330+
SandboxException.SandboxConfigurationException.class,
331+
() ->
332+
state.setVolumeMounts(
333+
List.of(
334+
new E2bVolumeMount("vol-a", "/mnt/data"),
335+
new E2bVolumeMount("vol-b", "/mnt/data/"))));
336+
}
337+
338+
@Test
339+
void serdeIgnoresUnknownFieldsForForwardCompat() {
340+
E2bSandboxClient client = new E2bSandboxClient();
341+
E2bSandboxState read =
342+
(E2bSandboxState)
343+
client.deserializeState(
344+
"{\"type\":\"e2b\",\"sessionId\":\"fwd\",\"workspaceRoot\":\"/home/user\","
345+
+ "\"someFutureField\":1}");
346+
assertEquals("fwd", read.getSessionId());
347+
assertTrue(read.getVolumeMounts().isEmpty());
348+
}
349+
350+
private static void invokeDoDestroyWorkspace(E2bSandbox sandbox) throws Exception {
351+
java.lang.reflect.Method m = E2bSandbox.class.getDeclaredMethod("doDestroyWorkspace");
352+
m.setAccessible(true);
353+
try {
354+
m.invoke(sandbox);
355+
} catch (java.lang.reflect.InvocationTargetException e) {
356+
if (e.getCause() instanceof Exception cause) {
357+
throw cause;
358+
}
359+
throw e;
360+
}
361+
}
362+
308363
private E2bSandboxClientOptions options() {
309364
E2bSandboxClientOptions opt = new E2bSandboxClientOptions();
310365
opt.setApiBaseUrl(server.url("/").toString());

‎agentscope-harness/src/main/java/io/agentscope/harness/agent/sandbox/WorkspaceMountSupport.java‎

Lines changed: 18 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -95,21 +95,35 @@ public static List<String> tarExcludeArgsForAbsolutePaths(
9595
|| absoluteMountPaths.isEmpty()) {
9696
return List.of();
9797
}
98-
String root = stripTrailingSlash(workspaceRoot.replace('\\', '/'));
98+
String root = normalizeAbsolutePath(workspaceRoot);
9999
List<String> args = new ArrayList<>();
100100
for (String raw : absoluteMountPaths) {
101101
if (raw == null || raw.isBlank()) {
102102
continue;
103103
}
104-
String mount = stripTrailingSlash(raw.replace('\\', '/'));
105-
if (!mount.startsWith("/") || mount.equals(root) || !mount.startsWith(root + "/")) {
104+
String mount = normalizeAbsolutePath(raw);
105+
if (!mount.startsWith("/") || mount.equals(root) || !isBeneath(root, mount)) {
106106
continue;
107107
}
108-
args.add("--exclude=./" + mount.substring(root.length() + 1));
108+
args.add("--exclude=./" + mount.substring(root.equals("/") ? 1 : root.length() + 1));
109109
}
110110
return args;
111111
}
112112

113+
private static boolean isBeneath(String root, String mount) {
114+
if (root.equals("/")) {
115+
return mount.length() > 1;
116+
}
117+
return mount.startsWith(root + "/");
118+
}
119+
120+
private static String normalizeAbsolutePath(String s) {
121+
// Collapse repeated slashes first, then drop the (single) trailing slash: the other
122+
// order leaves a trailing slash behind for inputs like "//mnt//data//".
123+
String normalized = stripTrailingSlash(s.replace('\\', '/').replaceAll("/+", "/"));
124+
return normalized.isEmpty() ? "/" : normalized;
125+
}
126+
113127
private static String stripTrailingSlash(String s) {
114128
if (s.length() > 1 && s.endsWith("/")) {
115129
return s.substring(0, s.length() - 1);

‎agentscope-harness/src/test/java/io/agentscope/harness/agent/sandbox/WorkspaceMountSupportTest.java‎

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -96,4 +96,20 @@ void tarExcludeArgsForAbsolutePaths_onlyNestedMounts() {
9696
"/home/user/", List.of("/home/user/a/b/")));
9797
assertEquals(List.of(), WorkspaceMountSupport.tarExcludeArgsForAbsolutePaths(null, null));
9898
}
99+
100+
@Test
101+
void tarExcludeArgsForAbsolutePaths_rootSlashAndNormalization() {
102+
assertEquals(
103+
List.of("--exclude=./mnt/data"),
104+
WorkspaceMountSupport.tarExcludeArgsForAbsolutePaths("/", List.of("/mnt/data")));
105+
assertEquals(
106+
List.of("--exclude=./mnt/data"),
107+
WorkspaceMountSupport.tarExcludeArgsForAbsolutePaths(
108+
"//", List.of("//mnt//data//")));
109+
assertEquals(
110+
List.of(), WorkspaceMountSupport.tarExcludeArgsForAbsolutePaths("/", List.of("/")));
111+
assertEquals(
112+
List.of(),
113+
WorkspaceMountSupport.tarExcludeArgsForAbsolutePaths("/", List.of("mnt/data")));
114+
}
99115
}

‎docs/v2/en/docs/harness/filesystem.md‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -224,11 +224,12 @@ Platform 400s (no beta access, unknown volume name) propagate with the response
224224

225225
When `workspaceRoot` sits on a volume mount, TAR workspace persistence becomes a no-op
226226
(the bytes are already durable) and destroy skips `rm -rf`; native snapshots still run so
227-
the software environment (installed deps, system state) is preserved. Volume lifecycle
227+
the software environment (installed deps, system state) is preserved. E2B volumes are
228+
read-write and can be mounted by multiple sandboxes at once with no locking guarantees —
229+
treat the workspace-on-volume layout as single-writer. Volume lifecycle
228230
management (create/delete/list) stays in the E2B dashboard/SDK — this covers attach-at-create
229231
only.
230232

231-
232233
#### Daytona sandbox
233234

234235
```java

0 commit comments

Comments
 (0)