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 b2b96393e..d28fa05fe 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 @@ -29,22 +29,31 @@ import java.util.List; import java.util.Map; import java.util.UUID; +import java.util.concurrent.atomic.AtomicReference; 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. The active {@link Sandbox} for an operation is + * resolved per call from the {@link RuntimeContext} (bound by {@link + * io.agentscope.harness.agent.middleware.SandboxLifecycleMiddleware} via {@code + * ctx.put(Sandbox.class, ...)}), so concurrent calls on the same agent instance each see their + * own sandbox. + * + *
The agent-level {@link SandboxAware} slot is kept only as a fallback for internal paths
+ * that operate without a per-call context (e.g. {@code RuntimeContext.empty()} maintenance
+ * writes); it must not be relied on when calls may run concurrently.
*/
public class SandboxBackedFilesystem extends BaseSandboxFilesystem implements SandboxAware {
private static final Logger log = LoggerFactory.getLogger(SandboxBackedFilesystem.class);
private final String fsId;
- private volatile Sandbox sandbox;
+
+ /** Fallback slot for context-less paths; compare-and-set cleared to avoid cross-call races. */
+ private final AtomicReference All per-call state ({@link Sandbox}, {@link SandboxAcquireResult}) is carried on the
+ * per-call {@link RuntimeContext} rather than on this (agent-level, shared) middleware
+ * instance, so concurrent calls on one agent never clear or release each other's sandbox.
+ *
* Post-call failures (persist, release) are logged but do not propagate — this ensures
* the agent call result is always returned to the caller even if sandbox cleanup fails.
*/
@@ -54,8 +58,6 @@ public class SandboxLifecycleMiddleware implements HarnessRuntimeMiddleware {
private final SandboxManager sandboxManager;
private final SandboxBackedFilesystem filesystemProxy;
- private final AtomicReference The acquire result is retrieved from the per-call context, so this only ever releases
+ * the sandbox that this call acquired — never a concurrent call's.
+ *
* @param ctx the per-call RuntimeContext (captured at acquire time)
*/
public void releaseForCall(RuntimeContext ctx) {
- SandboxAcquireResult result = currentAcquireResult.getAndSet(null);
+ if (ctx == null) {
+ return;
+ }
+ SandboxAcquireResult result = ctx.get(SandboxAcquireResult.class);
if (result == null) {
return;
}
- SandboxContext sandboxContext = ctx != null ? ctx.get(SandboxContext.class) : null;
+ ctx.put(SandboxAcquireResult.class, null);
+ ctx.put(Sandbox.class, null);
+ SandboxContext sandboxContext = ctx.get(SandboxContext.class);
try {
sandboxManager.persistState(result, sandboxContext, ctx);
} catch (Exception e) {
@@ -155,6 +171,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.clearSandbox(result.getSandbox());
}
}
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 17f4f7d17..2a61d7a76 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
@@ -143,6 +143,36 @@ void uploadFiles_reportsNativeTransferFailure() {
assertEquals("transfer down", responses.get(0).error());
}
+ @Test
+ void execute_prefersContextBoundSandboxOverFallbackSlot() {
+ SandboxBackedFilesystem filesystem = new SandboxBackedFilesystem();
+ FakeSandbox fallback = new FakeSandbox(new ExecResult(0, "fallback", "", false));
+ FakeSandbox bound = new FakeSandbox(new ExecResult(0, "bound", "", false));
+ filesystem.setSandbox(fallback);
+ RuntimeContext ctx = RuntimeContext.empty();
+ ctx.put(Sandbox.class, bound);
+
+ filesystem.execute(ctx, "whoami", 5);
+
+ assertEquals("whoami", bound.lastCommand);
+ assertEquals(null, fallback.lastCommand);
+ }
+
+ @Test
+ void clearSandbox_onlyClearsWhenSlotStillHoldsExpected() {
+ SandboxBackedFilesystem filesystem = new SandboxBackedFilesystem();
+ FakeSandbox first = new FakeSandbox(new ExecResult(0, "", "", false));
+ FakeSandbox second = new FakeSandbox(new ExecResult(0, "", "", false));
+
+ filesystem.setSandbox(first);
+ filesystem.setSandbox(second); // newer concurrent call overwrote the slot
+ filesystem.clearSandbox(first); // finishing older call must not wipe the newer one
+ assertEquals(second, filesystem.getSandbox());
+
+ filesystem.clearSandbox(second);
+ assertEquals(null, filesystem.getSandbox());
+ }
+
private static final class FakeTransferSandbox extends BaseFakeSandbox
implements SandboxFileTransfer {
diff --git a/agentscope-harness/src/test/java/io/agentscope/harness/agent/middleware/SandboxLifecycleMiddlewarePerCallBindingTest.java b/agentscope-harness/src/test/java/io/agentscope/harness/agent/middleware/SandboxLifecycleMiddlewarePerCallBindingTest.java
new file mode 100644
index 000000000..98707bc49
--- /dev/null
+++ b/agentscope-harness/src/test/java/io/agentscope/harness/agent/middleware/SandboxLifecycleMiddlewarePerCallBindingTest.java
@@ -0,0 +1,246 @@
+/*
+ * 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.assertNull;
+import static org.junit.jupiter.api.Assertions.assertSame;
+
+import io.agentscope.core.agent.RuntimeContext;
+import io.agentscope.core.state.InMemoryAgentStateStore;
+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.SandboxClient;
+import io.agentscope.harness.agent.sandbox.SandboxClientOptions;
+import io.agentscope.harness.agent.sandbox.SandboxContext;
+import io.agentscope.harness.agent.sandbox.SandboxManager;
+import io.agentscope.harness.agent.sandbox.SandboxState;
+import io.agentscope.harness.agent.sandbox.SessionSandboxStateStore;
+import io.agentscope.harness.agent.sandbox.WorkspaceSpec;
+import io.agentscope.harness.agent.sandbox.snapshot.SandboxSnapshotSpec;
+import java.io.InputStream;
+import java.util.ArrayDeque;
+import java.util.ArrayList;
+import java.util.Deque;
+import java.util.List;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+
+/**
+ * Regression tests for per-call sandbox binding: concurrent calls on the same agent instance
+ * must never observe, clear, or release each other's sandbox (previously the middleware and
+ * the filesystem proxy kept a single agent-level slot, so a finishing call wiped the sandbox
+ * of a still-running call — "No active sandbox" — or released the wrong sandbox session).
+ */
+class SandboxLifecycleMiddlewarePerCallBindingTest {
+
+ private RecordingSandboxManager manager;
+ private SandboxBackedFilesystem filesystemProxy;
+ private SandboxLifecycleMiddleware middleware;
+
+ @BeforeEach
+ void setUp() {
+ manager = new RecordingSandboxManager();
+ filesystemProxy = new SandboxBackedFilesystem();
+ middleware = new SandboxLifecycleMiddleware(manager, filesystemProxy);
+ }
+
+ private RuntimeContext newCallContext(String sessionId) {
+ RuntimeContext ctx = RuntimeContext.builder().userId("user").sessionId(sessionId).build();
+ ctx.put(SandboxContext.class, SandboxContext.builder().client(new StubClient()).build());
+ return ctx;
+ }
+
+ @Test
+ void concurrentCallsSeeTheirOwnSandbox() {
+ StubSandbox sandboxA = new StubSandbox("A");
+ StubSandbox sandboxB = new StubSandbox("B");
+ manager.toAcquire.add(sandboxA);
+ manager.toAcquire.add(sandboxB);
+
+ RuntimeContext ctxA = newCallContext("session-a");
+ RuntimeContext ctxB = newCallContext("session-b");
+ middleware.acquireForCall(ctxA);
+ middleware.acquireForCall(ctxB);
+
+ // Each call resolves its own sandbox even though B acquired after A
+ assertSame(sandboxA, ctxA.get(Sandbox.class));
+ assertSame(sandboxB, ctxB.get(Sandbox.class));
+
+ // Filesystem operations route to the caller's own sandbox
+ filesystemProxy.execute(ctxA, "echo a", 5);
+ assertEquals("echo a", sandboxA.lastCommand);
+ assertNull(sandboxB.lastCommand);
+ filesystemProxy.execute(ctxB, "echo b", 5);
+ assertEquals("echo b", sandboxB.lastCommand);
+ assertEquals("echo a", sandboxA.lastCommand);
+ }
+
+ @Test
+ void finishingCallDoesNotDisturbStillRunningCall() {
+ StubSandbox sandboxA = new StubSandbox("A");
+ StubSandbox sandboxB = new StubSandbox("B");
+ manager.toAcquire.add(sandboxA);
+ manager.toAcquire.add(sandboxB);
+
+ RuntimeContext ctxA = newCallContext("session-a");
+ RuntimeContext ctxB = newCallContext("session-b");
+ middleware.acquireForCall(ctxA);
+ middleware.acquireForCall(ctxB);
+
+ middleware.releaseForCall(ctxA);
+
+ // A released exactly its own sandbox, not B's
+ assertEquals(1, manager.released.size());
+ assertSame(sandboxA, manager.released.get(0).getSandbox());
+
+ // B keeps working: context binding intact, filesystem still routed to B
+ assertSame(sandboxB, ctxB.get(Sandbox.class));
+ filesystemProxy.execute(ctxB, "still alive", 5);
+ assertEquals("still alive", sandboxB.lastCommand);
+
+ // The fallback slot (held by B, the later acquirer) survives A's CAS-clear
+ assertSame(sandboxB, filesystemProxy.getSandbox());
+
+ middleware.releaseForCall(ctxB);
+ assertEquals(2, manager.released.size());
+ assertSame(sandboxB, manager.released.get(1).getSandbox());
+ assertNull(filesystemProxy.getSandbox());
+ }
+
+ @Test
+ void releaseForCallIsIdempotentPerContext() {
+ StubSandbox sandboxA = new StubSandbox("A");
+ manager.toAcquire.add(sandboxA);
+
+ RuntimeContext ctxA = newCallContext("session-a");
+ middleware.acquireForCall(ctxA);
+ middleware.releaseForCall(ctxA);
+ middleware.releaseForCall(ctxA);
+
+ assertEquals(1, manager.released.size());
+ assertNull(ctxA.get(Sandbox.class));
+ assertNull(ctxA.get(SandboxAcquireResult.class));
+ }
+
+ /** SandboxManager stub recording acquire/release pairing; base collaborators unused. */
+ private static final class RecordingSandboxManager extends SandboxManager {
+
+ private final DequedoFinally
@@ -42,9 +42,13 @@
*