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)); + } +}