diff --git a/agentscope-harness/src/main/java/io/agentscope/harness/agent/HarnessAgent.java b/agentscope-harness/src/main/java/io/agentscope/harness/agent/HarnessAgent.java
index d669184cb9..89c79cf767 100644
--- a/agentscope-harness/src/main/java/io/agentscope/harness/agent/HarnessAgent.java
+++ b/agentscope-harness/src/main/java/io/agentscope/harness/agent/HarnessAgent.java
@@ -2046,6 +2046,9 @@ public HarnessAgent build() {
SandboxLifecycleMiddleware sandboxLifecycleMw = null;
SandboxContext defaultSandboxContext = null;
SandboxBackedFilesystem capturedSandboxFs = null;
+ // bus/registry are process-scoped infrastructure; keep them on the pre-sandbox
+ // filesystem so they are never routed through SandboxBackedFilesystem
+ AbstractFilesystem busFilesystem = filesystem;
if (sandboxFilesystemSpec != null) {
capturedSandboxFs = new SandboxBackedFilesystem();
filesystem = capturedSandboxFs;
@@ -2097,16 +2100,17 @@ public HarnessAgent build() {
// ---- MessageBus / AsyncToolRegistry: workspace defaults ----
// If not set explicitly or via DistributedStore, fall back to workspace-backed
- // implementations that use the same AbstractFilesystem as the rest of the agent.
- if (messageBus == null && filesystem != null) {
+ // implementations. Use busFilesystem (pre-sandbox) so these process-scoped components
+ // are never accidentally routed through SandboxBackedFilesystem.
+ if (messageBus == null && busFilesystem != null) {
messageBus =
new io.agentscope.harness.agent.bus.WorkspaceMessageBus(
- filesystem, ".agentscope/bus");
+ busFilesystem, ".agentscope/bus");
}
- if (asyncToolRegistry == null && filesystem != null) {
+ if (asyncToolRegistry == null && busFilesystem != null) {
asyncToolRegistry =
new io.agentscope.harness.agent.bus.WorkspaceAsyncToolRegistry(
- filesystem, ".agentscope/bus/async-tools");
+ busFilesystem, ".agentscope/bus/async-tools");
}
// ---- Middlewares ----
diff --git a/agentscope-harness/src/main/java/io/agentscope/harness/agent/filesystem/sandbox/SandboxBackedFilesystem.java b/agentscope-harness/src/main/java/io/agentscope/harness/agent/filesystem/sandbox/SandboxBackedFilesystem.java
index 36903ae79e..8affbbcbb4 100644
--- a/agentscope-harness/src/main/java/io/agentscope/harness/agent/filesystem/sandbox/SandboxBackedFilesystem.java
+++ b/agentscope-harness/src/main/java/io/agentscope/harness/agent/filesystem/sandbox/SandboxBackedFilesystem.java
@@ -22,41 +22,49 @@
import io.agentscope.harness.agent.sandbox.ExecResult;
import io.agentscope.harness.agent.sandbox.Sandbox;
import io.agentscope.harness.agent.sandbox.SandboxAware;
+import io.agentscope.harness.agent.sandbox.SandboxBindingKey;
import io.agentscope.harness.agent.sandbox.SandboxException;
import java.util.ArrayList;
import java.util.Base64;
import java.util.List;
import java.util.Map;
import java.util.UUID;
+import java.util.concurrent.ConcurrentHashMap;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
/**
* A {@link BaseSandboxFilesystem} that delegates execution to a live {@link Sandbox}.
*
- *
Stable proxy created at agent build time; a fresh {@link Sandbox} is injected on each call
- * via the volatile {@code sandbox} field by {@link
- * io.agentscope.harness.agent.middleware.SandboxLifecycleMiddleware}.
+ *
Stable proxy created at agent build time. A sandbox is bound per session key on each call via
+ * {@link io.agentscope.harness.agent.middleware.SandboxLifecycleMiddleware}, allowing a single
+ * proxy instance to serve concurrent sessions without cross-user contamination.
*/
public class SandboxBackedFilesystem extends BaseSandboxFilesystem implements SandboxAware {
private static final Logger log = LoggerFactory.getLogger(SandboxBackedFilesystem.class);
private final String fsId;
- private volatile Sandbox sandbox;
+ // per-call sandbox bindings; key is "userId/sessionId" (aligned with ReActAgent.slotKey)
+ private final ConcurrentHashMap activeSandboxes = new ConcurrentHashMap<>();
public SandboxBackedFilesystem() {
this.fsId = "sandbox-" + UUID.randomUUID().toString().substring(0, 8);
}
@Override
- public void setSandbox(Sandbox sandbox) {
- this.sandbox = sandbox;
+ public void bindSandbox(String sessionKey, Sandbox sandbox) {
+ activeSandboxes.put(sessionKey, sandbox);
}
@Override
- public Sandbox getSandbox() {
- return sandbox;
+ public void unbindSandbox(String sessionKey) {
+ activeSandboxes.remove(sessionKey);
+ }
+
+ @Override
+ public Sandbox getSandbox(String sessionKey) {
+ return activeSandboxes.get(sessionKey);
}
@Override
@@ -67,7 +75,7 @@ public String id() {
@Override
public ExecuteResponse execute(
RuntimeContext runtimeContext, String command, Integer timeoutSeconds) {
- Sandbox active = requireSandbox();
+ Sandbox active = requireSandbox(runtimeContext);
try {
ExecResult result = active.exec(runtimeContext, command, timeoutSeconds);
return new ExecuteResponse(
@@ -90,7 +98,7 @@ public ExecuteResponse execute(
@Override
public List uploadFiles(
RuntimeContext runtimeContext, List> files) {
- Sandbox active = requireSandbox();
+ Sandbox active = requireSandbox(runtimeContext);
List results = new ArrayList<>(files.size());
for (Map.Entry file : files) {
@@ -134,7 +142,7 @@ public List uploadFiles(
@Override
public List downloadFiles(
RuntimeContext runtimeContext, List paths) {
- Sandbox active = requireSandbox();
+ Sandbox active = requireSandbox(runtimeContext);
List results = new ArrayList<>(paths.size());
for (String path : paths) {
@@ -168,11 +176,14 @@ public List downloadFiles(
return results;
}
- private Sandbox requireSandbox() {
- Sandbox s = sandbox;
+ private Sandbox requireSandbox(RuntimeContext rc) {
+ String key = rc != null ? SandboxBindingKey.resolve(rc) : null;
+ Sandbox s = key != null ? getSandbox(key) : null;
if (s == null) {
throw new SandboxException.SandboxConfigurationException(
- "No active sandbox — sandbox filesystem used outside of a call context");
+ "No active sandbox for session '"
+ + key
+ + "' — sandbox filesystem used outside of a call context");
}
return s;
}
diff --git a/agentscope-harness/src/main/java/io/agentscope/harness/agent/middleware/SandboxLifecycleMiddleware.java b/agentscope-harness/src/main/java/io/agentscope/harness/agent/middleware/SandboxLifecycleMiddleware.java
index a33ff6edc5..56c718cb4d 100644
--- a/agentscope-harness/src/main/java/io/agentscope/harness/agent/middleware/SandboxLifecycleMiddleware.java
+++ b/agentscope-harness/src/main/java/io/agentscope/harness/agent/middleware/SandboxLifecycleMiddleware.java
@@ -19,9 +19,10 @@
import io.agentscope.harness.agent.filesystem.sandbox.SandboxBackedFilesystem;
import io.agentscope.harness.agent.sandbox.Sandbox;
import io.agentscope.harness.agent.sandbox.SandboxAcquireResult;
+import io.agentscope.harness.agent.sandbox.SandboxBindingKey;
import io.agentscope.harness.agent.sandbox.SandboxContext;
import io.agentscope.harness.agent.sandbox.SandboxManager;
-import java.util.concurrent.atomic.AtomicReference;
+import java.util.concurrent.ConcurrentHashMap;
import java.util.function.Consumer;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
@@ -54,9 +55,9 @@ public class SandboxLifecycleMiddleware implements HarnessRuntimeMiddleware {
private final SandboxManager sandboxManager;
private final SandboxBackedFilesystem filesystemProxy;
- private final AtomicReference currentAcquireResult =
- new AtomicReference<>();
private volatile Consumer beforeStartCallback;
+ private final ConcurrentHashMap acquireResults =
+ new ConcurrentHashMap<>();
public SandboxLifecycleMiddleware(
SandboxManager sandboxManager, SandboxBackedFilesystem filesystemProxy) {
@@ -77,11 +78,13 @@ public void setBeforeStartCallback(Consumer callback) {
}
/**
- * Acquires the sandbox for the current call. Called from
- * {@code ReActAgent.beforeAgentExecution()} to ensure the sandbox is available
- * for both the {@code call()} and {@code streamEvents()} paths.
+ * Acquires the sandbox for the current call, binding it under the call's session key.
*
- * @param ctx the per-call RuntimeContext (must not be null)
+ * Intended to be wrapped by the caller in a {@code Mono.using}/{@code Flux.using} so that
+ * {@link #releaseForCall} is always invoked on termination (complete/error/cancel). This
+ * method does not serialize same-session concurrency; callers must ensure per-session
+ * non-concurrency at the entry point, or configure a distributed {@code SandboxExecutionGuard}
+ * whose lease serializes by {@code SandboxIsolationKey}.
*/
public void acquireForCall(RuntimeContext ctx) {
if (ctx == null) {
@@ -91,6 +94,30 @@ public void acquireForCall(RuntimeContext ctx) {
if (sandboxContext == null) {
return;
}
+ String sessionKey = SandboxBindingKey.resolve(ctx);
+ if (sessionKey == null) {
+ return;
+ }
+ doAcquire(ctx, sandboxContext, sessionKey);
+ }
+
+ /**
+ * Releases the sandbox acquired by {@link #acquireForCall}. Intended as the cleanup step of
+ * a {@code Mono.using}/{@code Flux.using} wrapper; failures are logged but do not propagate.
+ */
+ public void releaseForCall(RuntimeContext ctx) {
+ doRelease(ctx);
+ }
+
+ /**
+ * Acquires a sandbox session: runs the optional {@link #beforeStartCallback},
+ * obtains a lease via {@link SandboxManager#acquire}, starts the sandbox, and
+ * binds it to the filesystem proxy under {@code sessionKey}.
+ *
+ *
On failure after acquire, the sandbox is released and the lease closed to
+ * prevent resource leaks.
+ */
+ private void doAcquire(RuntimeContext ctx, SandboxContext sandboxContext, String sessionKey) {
try {
Consumer cb = beforeStartCallback;
if (cb != null) {
@@ -108,13 +135,14 @@ public void acquireForCall(RuntimeContext ctx) {
Sandbox sandbox = result.getSandbox();
try {
sandbox.start();
- filesystemProxy.setSandbox(sandbox);
- currentAcquireResult.set(result);
+ filesystemProxy.bindSandbox(sessionKey, sandbox);
+ acquireResults.put(sessionKey, result);
log.debug(
- "[sandbox-mw] Acquired sandbox {}",
- sandbox.getState() != null ? sandbox.getState().getSessionId() : "?");
+ "[sandbox-mw] Acquired sandbox {} for session {}",
+ sandbox.getState() != null ? sandbox.getState().getSessionId() : "?",
+ sessionKey);
} catch (Exception e) {
- filesystemProxy.setSandbox(null);
+ filesystemProxy.unbindSandbox(sessionKey);
try {
sandboxManager.release(result);
} catch (Exception releaseErr) {
@@ -133,17 +161,20 @@ public void acquireForCall(RuntimeContext ctx) {
}
/**
- * Releases the sandbox after the current call. Called from
- * {@code ReActAgent.afterAgentExecution()} to ensure cleanup for both paths.
+ * Releases a previously acquired sandbox session: persists state, releases the
+ * sandbox via {@link SandboxManager#release}, closes the lease, and unbinds the
+ * filesystem proxy.
*
- * @param ctx the per-call RuntimeContext (captured at acquire time)
+ * Failures during persist or release are logged but do not propagate, ensuring
+ * the agent call result is always delivered to the caller.
*/
- public void releaseForCall(RuntimeContext ctx) {
- SandboxAcquireResult result = currentAcquireResult.getAndSet(null);
+ private void doRelease(RuntimeContext ctx) {
+ String sessionKey = ctx != null ? SandboxBindingKey.resolve(ctx) : null;
+ SandboxAcquireResult result = sessionKey != null ? acquireResults.remove(sessionKey) : null;
if (result == null) {
return;
}
- SandboxContext sandboxContext = ctx != null ? ctx.get(SandboxContext.class) : null;
+ SandboxContext sandboxContext = ctx.get(SandboxContext.class);
try {
sandboxManager.persistState(result, sandboxContext, ctx);
} catch (Exception e) {
@@ -155,6 +186,6 @@ public void releaseForCall(RuntimeContext ctx) {
log.warn("[sandbox-mw] Failed to release sandbox session: {}", e.getMessage(), e);
}
result.getLease().close();
- filesystemProxy.setSandbox(null);
+ filesystemProxy.unbindSandbox(sessionKey);
}
}
diff --git a/agentscope-harness/src/main/java/io/agentscope/harness/agent/sandbox/SandboxAware.java b/agentscope-harness/src/main/java/io/agentscope/harness/agent/sandbox/SandboxAware.java
index d6904e8512..40532b133a 100644
--- a/agentscope-harness/src/main/java/io/agentscope/harness/agent/sandbox/SandboxAware.java
+++ b/agentscope-harness/src/main/java/io/agentscope/harness/agent/sandbox/SandboxAware.java
@@ -21,12 +21,16 @@
* Marks a filesystem that can have its backing {@link Sandbox} injected at runtime.
*
*
Implemented by {@link SandboxBackedFilesystem} so {@link
- * io.agentscope.harness.agent.middleware.SandboxLifecycleMiddleware} can set the active sandbox for each
- * call and clear it afterward.
+ * io.agentscope.harness.agent.middleware.SandboxLifecycleMiddleware} can bind and unbind a sandbox
+ * per session key, allowing a single filesystem proxy to serve concurrent sessions safely.
*/
public interface SandboxAware {
- void setSandbox(Sandbox sandbox);
+ // Binds a live sandbox to the given session key for the duration of one call.
+ void bindSandbox(String sessionKey, Sandbox sandbox);
- Sandbox getSandbox();
+ // Removes the sandbox binding for the given session key after a call completes.
+ void unbindSandbox(String sessionKey);
+
+ Sandbox getSandbox(String sessionKey);
}
diff --git a/agentscope-harness/src/main/java/io/agentscope/harness/agent/sandbox/SandboxBindingKey.java b/agentscope-harness/src/main/java/io/agentscope/harness/agent/sandbox/SandboxBindingKey.java
new file mode 100644
index 0000000000..b523b8b776
--- /dev/null
+++ b/agentscope-harness/src/main/java/io/agentscope/harness/agent/sandbox/SandboxBindingKey.java
@@ -0,0 +1,52 @@
+/*
+ * Copyright 2024-2026 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package io.agentscope.harness.agent.sandbox;
+
+import io.agentscope.core.agent.RuntimeContext;
+
+/**
+ * Resolves the per-call sandbox binding key ({@code "userId/sessionId"}) used to slot
+ * live {@link Sandbox} instances in a shared filesystem proxy.
+ *
+ *
Distinct from {@link SandboxIsolationKey}, which governs the coarser acquire / lease /
+ * persist isolation granularity (scope-based). This key is the per-session handle under which
+ * {@code SandboxLifecycleMiddleware} binds a sandbox for the duration of one call.
+ */
+public final class SandboxBindingKey {
+
+ private static final String ANONYMOUS_USER = "__anon__";
+
+ private SandboxBindingKey() {}
+
+ /**
+ * Resolves {@code "userId/sessionId"} from the given context.
+ *
+ * @param ctx runtime context; may be {@code null}
+ * @return the binding key, or {@code null} when {@code sessionId} is absent (signalling
+ * that no sandbox should be bound for this call)
+ */
+ public static String resolve(RuntimeContext ctx) {
+ if (ctx == null) {
+ return null;
+ }
+ String sid = ctx.getSessionId();
+ if (sid == null || sid.isBlank()) {
+ return null;
+ }
+ String uid = ctx.getUserId();
+ return (uid == null || uid.isBlank() ? ANONYMOUS_USER : uid) + "/" + sid;
+ }
+}
diff --git a/agentscope-harness/src/test/java/io/agentscope/harness/agent/filesystem/sandbox/SandboxBackedFilesystemTest.java b/agentscope-harness/src/test/java/io/agentscope/harness/agent/filesystem/sandbox/SandboxBackedFilesystemTest.java
index 6e2c740ad2..58c77effc0 100644
--- a/agentscope-harness/src/test/java/io/agentscope/harness/agent/filesystem/sandbox/SandboxBackedFilesystemTest.java
+++ b/agentscope-harness/src/test/java/io/agentscope/harness/agent/filesystem/sandbox/SandboxBackedFilesystemTest.java
@@ -17,30 +17,52 @@
import static org.junit.jupiter.api.Assertions.assertArrayEquals;
import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
import io.agentscope.core.agent.RuntimeContext;
import io.agentscope.harness.agent.filesystem.model.FileDownloadResponse;
import io.agentscope.harness.agent.sandbox.ExecResult;
import io.agentscope.harness.agent.sandbox.Sandbox;
+import io.agentscope.harness.agent.sandbox.SandboxException;
import io.agentscope.harness.agent.sandbox.SandboxState;
import java.io.InputStream;
import java.util.List;
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Executors;
+import java.util.concurrent.TimeUnit;
import org.junit.jupiter.api.Test;
class SandboxBackedFilesystemTest {
- private static final RuntimeContext RT = RuntimeContext.empty();
+ private static RuntimeContext rc(String sessionId) {
+ return RuntimeContext.builder().sessionId(sessionId).build();
+ }
+
+ private static RuntimeContext rc(String userId, String sessionId) {
+ return RuntimeContext.builder().userId(userId).sessionId(sessionId).build();
+ }
+
+ // mirrors the binding key logic in SandboxBackedFilesystem / SandboxLifecycleMiddleware
+ private static String bindKey(RuntimeContext ctx) {
+ String uid = ctx.getUserId();
+ String sid = ctx.getSessionId();
+ return (uid == null || uid.isBlank() ? "__anon__" : uid) + "/" + sid;
+ }
@Test
void downloadFiles_decodesWrappedBase64Output() {
byte[] expected = new byte[] {1, 2, 3, 4, 5, 6};
SandboxBackedFilesystem filesystem = new SandboxBackedFilesystem();
FakeSandbox sandbox = new FakeSandbox(new ExecResult(0, "AQID\nBAUG", "", false));
- filesystem.setSandbox(sandbox);
+ RuntimeContext ctx = rc("session-a");
+ filesystem.bindSandbox(bindKey(ctx), sandbox);
List responses =
- filesystem.downloadFiles(RT, List.of("/tmp/data.bin"));
+ filesystem.downloadFiles(ctx, List.of("/tmp/data.bin"));
assertEquals("base64 '/tmp/data.bin'", sandbox.lastCommand);
assertEquals(1, responses.size());
@@ -53,10 +75,11 @@ void downloadFiles_decodesWrappedBase64Output() {
void downloadFiles_decodesEmptyPayloadWhenStdoutIsNull() {
SandboxBackedFilesystem filesystem = new SandboxBackedFilesystem();
FakeSandbox sandbox = new FakeSandbox(new ExecResult(0, null, "", false));
- filesystem.setSandbox(sandbox);
+ RuntimeContext ctx = rc("session-a");
+ filesystem.bindSandbox(bindKey(ctx), sandbox);
List responses =
- filesystem.downloadFiles(RT, List.of("/tmp/empty.bin"));
+ filesystem.downloadFiles(ctx, List.of("/tmp/empty.bin"));
assertEquals("base64 '/tmp/empty.bin'", sandbox.lastCommand);
assertEquals(1, responses.size());
@@ -69,10 +92,11 @@ void downloadFiles_decodesEmptyPayloadWhenStdoutIsNull() {
void downloadFiles_returnsFailureWhenCommandFails() {
SandboxBackedFilesystem filesystem = new SandboxBackedFilesystem();
FakeSandbox sandbox = new FakeSandbox(new ExecResult(1, "", "boom", false));
- filesystem.setSandbox(sandbox);
+ RuntimeContext ctx = rc("session-a");
+ filesystem.bindSandbox(bindKey(ctx), sandbox);
List responses =
- filesystem.downloadFiles(RT, List.of("/tmp/fail.bin"));
+ filesystem.downloadFiles(ctx, List.of("/tmp/fail.bin"));
assertEquals("base64 '/tmp/fail.bin'", sandbox.lastCommand);
assertEquals(1, responses.size());
@@ -81,10 +105,112 @@ void downloadFiles_returnsFailureWhenCommandFails() {
assertEquals("[stderr] boom", responses.get(0).error());
}
+ @Test
+ void requireSandbox_throwsWhenNoBindingForSession() {
+ SandboxBackedFilesystem filesystem = new SandboxBackedFilesystem();
+ RuntimeContext ctx = rc("unknown-session");
+
+ assertThrows(
+ SandboxException.SandboxConfigurationException.class,
+ () -> filesystem.execute(ctx, "echo hi", null));
+ }
+
+ @Test
+ void bindAndUnbind_isolatesSessionsFromEachOther() {
+ SandboxBackedFilesystem filesystem = new SandboxBackedFilesystem();
+ FakeSandbox sandboxA = new FakeSandbox(new ExecResult(0, "AAAA", "", false));
+ FakeSandbox sandboxB = new FakeSandbox(new ExecResult(0, "BBBB", "", false));
+
+ RuntimeContext ctxA = rc("session-a");
+ RuntimeContext ctxB = rc("session-b");
+ filesystem.bindSandbox(bindKey(ctxA), sandboxA);
+ filesystem.bindSandbox(bindKey(ctxB), sandboxB);
+
+ filesystem.downloadFiles(ctxA, List.of("/f"));
+ assertNotNull(sandboxA.lastCommand);
+ assertNull(sandboxB.lastCommand);
+
+ filesystem.downloadFiles(ctxB, List.of("/g"));
+ assertNotNull(sandboxB.lastCommand);
+
+ filesystem.unbindSandbox(bindKey(ctxA));
+ assertThrows(
+ SandboxException.SandboxConfigurationException.class,
+ () -> filesystem.execute(ctxA, "echo", null));
+ filesystem.downloadFiles(ctxB, List.of("/h"));
+ }
+
+ @Test
+ void concurrentSessionsDontCrossContaminate() throws InterruptedException {
+ int sessions = 8;
+ SandboxBackedFilesystem filesystem = new SandboxBackedFilesystem();
+ FakeSandbox[] sandboxes = new FakeSandbox[sessions];
+ RuntimeContext[] contexts = new RuntimeContext[sessions];
+ for (int i = 0; i < sessions; i++) {
+ sandboxes[i] = new FakeSandbox(new ExecResult(0, "AA==", "", false));
+ contexts[i] = rc("session-" + i);
+ filesystem.bindSandbox(bindKey(contexts[i]), sandboxes[i]);
+ }
+
+ CountDownLatch start = new CountDownLatch(1);
+ CountDownLatch done = new CountDownLatch(sessions);
+ ExecutorService pool = Executors.newFixedThreadPool(sessions);
+ String[] errors = new String[sessions];
+
+ for (int i = 0; i < sessions; i++) {
+ final int idx = i;
+ pool.submit(
+ () -> {
+ try {
+ start.await();
+ filesystem.execute(contexts[idx], "cmd-" + idx, null);
+ String expected = "cmd-" + idx;
+ if (!expected.equals(sandboxes[idx].lastCommand)) {
+ errors[idx] =
+ "session-"
+ + idx
+ + " routed to wrong sandbox: "
+ + sandboxes[idx].lastCommand;
+ }
+ } catch (Exception e) {
+ errors[idx] = e.getMessage();
+ } finally {
+ done.countDown();
+ }
+ });
+ }
+
+ start.countDown();
+ assertTrue(done.await(10, TimeUnit.SECONDS));
+ pool.shutdown();
+
+ for (int i = 0; i < sessions; i++) {
+ assertNull(errors[i], "Error in session-" + i + ": " + errors[i]);
+ }
+ }
+
+ @Test
+ void sameSessionIdDifferentUsersAreIsolated() {
+ SandboxBackedFilesystem filesystem = new SandboxBackedFilesystem();
+ FakeSandbox sandboxAlice = new FakeSandbox(new ExecResult(0, "AA==", "", false));
+ FakeSandbox sandboxBob = new FakeSandbox(new ExecResult(0, "AA==", "", false));
+
+ RuntimeContext ctxAlice = rc("alice", "default");
+ RuntimeContext ctxBob = rc("bob", "default");
+ filesystem.bindSandbox(bindKey(ctxAlice), sandboxAlice);
+ filesystem.bindSandbox(bindKey(ctxBob), sandboxBob);
+
+ filesystem.execute(ctxAlice, "alice-cmd", null);
+ filesystem.execute(ctxBob, "bob-cmd", null);
+
+ assertEquals("alice-cmd", sandboxAlice.lastCommand);
+ assertEquals("bob-cmd", sandboxBob.lastCommand);
+ }
+
private static final class FakeSandbox implements Sandbox {
private final ExecResult execResult;
- private String lastCommand;
+ volatile String lastCommand;
private FakeSandbox(ExecResult execResult) {
this.execResult = execResult;
diff --git a/agentscope-harness/src/test/java/io/agentscope/harness/agent/middleware/SandboxLifecycleMiddlewareTest.java b/agentscope-harness/src/test/java/io/agentscope/harness/agent/middleware/SandboxLifecycleMiddlewareTest.java
new file mode 100644
index 0000000000..2da5d53d0c
--- /dev/null
+++ b/agentscope-harness/src/test/java/io/agentscope/harness/agent/middleware/SandboxLifecycleMiddlewareTest.java
@@ -0,0 +1,174 @@
+/*
+ * Copyright 2024-2026 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package io.agentscope.harness.agent.middleware;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.doNothing;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+import io.agentscope.core.agent.RuntimeContext;
+import io.agentscope.core.message.AssistantMessage;
+import io.agentscope.harness.agent.filesystem.sandbox.SandboxBackedFilesystem;
+import io.agentscope.harness.agent.sandbox.ExecResult;
+import io.agentscope.harness.agent.sandbox.Sandbox;
+import io.agentscope.harness.agent.sandbox.SandboxAcquireResult;
+import io.agentscope.harness.agent.sandbox.SandboxBindingKey;
+import io.agentscope.harness.agent.sandbox.SandboxContext;
+import io.agentscope.harness.agent.sandbox.SandboxManager;
+import io.agentscope.harness.agent.sandbox.SandboxState;
+import java.io.InputStream;
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.atomic.AtomicInteger;
+import org.junit.jupiter.api.Test;
+import reactor.core.publisher.Mono;
+import reactor.core.scheduler.Schedulers;
+
+class SandboxLifecycleMiddlewareTest {
+
+ private static RuntimeContext ctxWithSandbox(String sessionId) {
+ return RuntimeContext.builder()
+ .sessionId(sessionId)
+ .put(SandboxContext.class, SandboxContext.builder().build())
+ .build();
+ }
+
+ private static SandboxManager mockManager(AtomicInteger acquireCount) throws Exception {
+ SandboxManager manager = mock(SandboxManager.class);
+ when(manager.acquire(any(), any()))
+ .thenAnswer(
+ inv -> {
+ acquireCount.incrementAndGet();
+ return SandboxAcquireResult.selfManaged(new NoopSandbox());
+ });
+ doNothing().when(manager).persistState(any(), any(), any());
+ doNothing().when(manager).release(any());
+ return manager;
+ }
+
+ @Test
+ void acquireForCall_differentSessions_runConcurrently() throws Exception {
+ AtomicInteger acquireCount = new AtomicInteger();
+ CountDownLatch bothAcquired = new CountDownLatch(2);
+
+ SandboxManager manager = mockManager(acquireCount);
+ SandboxBackedFilesystem fs = new SandboxBackedFilesystem();
+ SandboxLifecycleMiddleware mw = new SandboxLifecycleMiddleware(manager, fs);
+
+ RuntimeContext ctxA = ctxWithSandbox("session-a");
+ RuntimeContext ctxB = ctxWithSandbox("session-b");
+
+ CountDownLatch done = new CountDownLatch(2);
+
+ Mono.using(
+ () -> {
+ mw.acquireForCall(ctxA);
+ return ctxA;
+ },
+ ctx ->
+ Mono.fromCallable(
+ () -> {
+ bothAcquired.countDown();
+ bothAcquired.await(3, TimeUnit.SECONDS);
+ return new AssistantMessage("a");
+ }),
+ ctx -> mw.releaseForCall(ctx))
+ .subscribeOn(Schedulers.boundedElastic())
+ .doFinally(s -> done.countDown())
+ .subscribe();
+
+ Mono.using(
+ () -> {
+ mw.acquireForCall(ctxB);
+ return ctxB;
+ },
+ ctx ->
+ Mono.fromCallable(
+ () -> {
+ bothAcquired.countDown();
+ bothAcquired.await(3, TimeUnit.SECONDS);
+ return new AssistantMessage("b");
+ }),
+ ctx -> mw.releaseForCall(ctx))
+ .subscribeOn(Schedulers.boundedElastic())
+ .doFinally(s -> done.countDown())
+ .subscribe();
+
+ assertTrue(bothAcquired.await(3, TimeUnit.SECONDS));
+ assertTrue(done.await(5, TimeUnit.SECONDS));
+ assertEquals(2, acquireCount.get());
+ }
+
+ @Test
+ void acquireForCall_releaseForCall_bindsAndUnbindsSandbox() throws Exception {
+ AtomicInteger acquireCount = new AtomicInteger();
+ SandboxManager manager = mockManager(acquireCount);
+ SandboxBackedFilesystem fs = new SandboxBackedFilesystem();
+ SandboxLifecycleMiddleware mw = new SandboxLifecycleMiddleware(manager, fs);
+
+ RuntimeContext ctx = ctxWithSandbox("solo-session");
+ String expectedKey = SandboxBindingKey.resolve(ctx);
+
+ mw.acquireForCall(ctx);
+ assertEquals(1, acquireCount.get());
+ assertNotNull(fs.getSandbox(expectedKey));
+
+ mw.releaseForCall(ctx);
+ verify(manager).release(any());
+ // sandbox is unbound after release
+ assertNull(fs.getSandbox(expectedKey));
+ }
+
+ private static class NoopSandbox implements Sandbox {
+ @Override
+ public void start() {}
+
+ @Override
+ public void stop() {}
+
+ @Override
+ public void close() {}
+
+ @Override
+ public boolean isRunning() {
+ return true;
+ }
+
+ @Override
+ public SandboxState getState() {
+ return null;
+ }
+
+ @Override
+ public ExecResult exec(RuntimeContext rc, String cmd, Integer timeout) {
+ return new ExecResult(0, "", "", false);
+ }
+
+ @Override
+ public InputStream persistWorkspace() {
+ return InputStream.nullInputStream();
+ }
+
+ @Override
+ public void hydrateWorkspace(InputStream archive) {}
+ }
+}
diff --git a/agentscope-harness/src/test/java/io/agentscope/harness/agent/sandbox/SandboxBindingKeyTest.java b/agentscope-harness/src/test/java/io/agentscope/harness/agent/sandbox/SandboxBindingKeyTest.java
new file mode 100644
index 0000000000..7da211440d
--- /dev/null
+++ b/agentscope-harness/src/test/java/io/agentscope/harness/agent/sandbox/SandboxBindingKeyTest.java
@@ -0,0 +1,48 @@
+/*
+ * Copyright 2024-2026 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package io.agentscope.harness.agent.sandbox;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNull;
+
+import io.agentscope.core.agent.RuntimeContext;
+import org.junit.jupiter.api.Test;
+
+class SandboxBindingKeyTest {
+
+ @Test
+ void resolve_nullContext_returnsNull() {
+ assertNull(SandboxBindingKey.resolve(null));
+ }
+
+ @Test
+ void resolve_sessionIdAbsent_returnsNull() {
+ RuntimeContext ctx = RuntimeContext.builder().userId("u1").build();
+ assertNull(SandboxBindingKey.resolve(ctx));
+ }
+
+ @Test
+ void resolve_userIdAbsent_usesAnonymous() {
+ RuntimeContext ctx = RuntimeContext.builder().sessionId("s1").build();
+ assertEquals("__anon__/s1", SandboxBindingKey.resolve(ctx));
+ }
+
+ @Test
+ void resolve_bothPresent_compositeKey() {
+ RuntimeContext ctx = RuntimeContext.builder().userId("u1").sessionId("s1").build();
+ assertEquals("u1/s1", SandboxBindingKey.resolve(ctx));
+ }
+}