From 2e6ef4140a826ffdddc3fa1c8486136cf97281d9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=B1=9F=E5=AE=9D=E5=9D=A4?= Date: Mon, 29 Jun 2026 21:18:57 +0800 Subject: [PATCH] fix(harness): fix concurrent sandbox isolation in multi-user singleton deployments Two related bugs in HarnessAgent when used as a singleton serving concurrent sessions: Bug 1: SandboxBackedFilesystem used a single volatile Sandbox field and SandboxLifecycleMiddleware used a single AtomicReference. Concurrent sessions overwrote each other's bindings, causing cross-user sandbox contamination. Fixed by replacing both with ConcurrentHashMap, keyed by sessionId so each session gets an isolated slot. SandboxAware interface updated: setSandbox/getSandbox replaced by bindSandbox/unbindSandbox/getSandbox(key). Bug 2: WorkspaceMessageBus and WorkspaceAsyncToolRegistry were constructed with the post-sandbox filesystem (SandboxBackedFilesystem) in HarnessAgent.build(). These are process-scoped infrastructure components; routing them through the sandbox filesystem causes RuntimeContext.empty() calls to fail or contaminate sandbox state. Fixed by capturing busFilesystem before the sandbox replacement and using it exclusively for bus/registry construction. Closes #1896 --- .../harness/agent/HarnessAgent.java | 14 ++- .../sandbox/SandboxBackedFilesystem.java | 38 +++--- .../SandboxLifecycleMiddleware.java | 26 ++-- .../harness/agent/sandbox/SandboxAware.java | 12 +- .../sandbox/SandboxBackedFilesystemTest.java | 115 ++++++++++++++++-- 5 files changed, 163 insertions(+), 42 deletions(-) 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 2a3b8f7b03..278dee1cce 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 @@ -2047,6 +2047,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; @@ -2098,16 +2101,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..2e7a947c91 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 @@ -28,35 +28,42 @@ 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-session sandbox bindings; key is sessionId (or userId for USER isolation scope) + 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 +74,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 +97,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 +141,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 +175,14 @@ public List downloadFiles( return results; } - private Sandbox requireSandbox() { - Sandbox s = sandbox; + private Sandbox requireSandbox(RuntimeContext rc) { + String key = rc != null ? rc.getSessionId() : null; + Sandbox s = key != null ? activeSandboxes.get(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 d754345c79..236d58a0bc 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 @@ -21,7 +21,7 @@ import io.agentscope.harness.agent.sandbox.SandboxAcquireResult; 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 org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -53,8 +53,9 @@ public class SandboxLifecycleMiddleware implements HarnessRuntimeMiddleware { private final SandboxManager sandboxManager; private final SandboxBackedFilesystem filesystemProxy; - private final AtomicReference currentAcquireResult = - new AtomicReference<>(); + // per-session acquire results; keyed by sessionId so concurrent sessions don't interfere + private final ConcurrentHashMap acquireResults = + new ConcurrentHashMap<>(); public SandboxLifecycleMiddleware( SandboxManager sandboxManager, SandboxBackedFilesystem filesystemProxy) { @@ -77,18 +78,20 @@ public void acquireForCall(RuntimeContext ctx) { if (sandboxContext == null) { return; } + String sessionKey = ctx.getSessionId(); try { SandboxAcquireResult result = sandboxManager.acquire(sandboxContext, 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) { @@ -113,11 +116,12 @@ public void acquireForCall(RuntimeContext ctx) { * @param ctx the per-call RuntimeContext (captured at acquire time) */ public void releaseForCall(RuntimeContext ctx) { - SandboxAcquireResult result = currentAcquireResult.getAndSet(null); + String sessionKey = ctx != null ? ctx.getSessionId() : 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) { @@ -129,6 +133,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/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..6f2d79a5ab 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,41 @@ 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(); + } @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(ctx.getSessionId(), 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 +64,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(ctx.getSessionId(), 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 +81,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(ctx.getSessionId(), 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 +94,96 @@ 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)); + + filesystem.bindSandbox("session-a", sandboxA); + filesystem.bindSandbox("session-b", sandboxB); + + // session-a routes to sandboxA + filesystem.downloadFiles(rc("session-a"), List.of("/f")); + assertNotNull(sandboxA.lastCommand); + assertNull(sandboxB.lastCommand); + + // session-b routes to sandboxB + filesystem.downloadFiles(rc("session-b"), List.of("/g")); + assertNotNull(sandboxB.lastCommand); + + // unbind session-a; session-b still works + filesystem.unbindSandbox("session-a"); + assertThrows( + SandboxException.SandboxConfigurationException.class, + () -> filesystem.execute(rc("session-a"), "echo", null)); + filesystem.downloadFiles(rc("session-b"), List.of("/h")); // must not throw + } + + @Test + void concurrentSessionsDontCrossContaminate() throws InterruptedException { + int sessions = 8; + SandboxBackedFilesystem filesystem = new SandboxBackedFilesystem(); + FakeSandbox[] sandboxes = new FakeSandbox[sessions]; + for (int i = 0; i < sessions; i++) { + sandboxes[i] = new FakeSandbox(new ExecResult(0, "AA==", "", false)); // 1 byte + filesystem.bindSandbox("session-" + 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(); + RuntimeContext ctx = rc("session-" + idx); + // each session executes a uniquely named command + filesystem.execute(ctx, "cmd-" + idx, null); + // verify command reached the correct sandbox + 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]); + } + } + private static final class FakeSandbox implements Sandbox { private final ExecResult execResult; - private String lastCommand; + volatile String lastCommand; private FakeSandbox(ExecResult execResult) { this.execResult = execResult;