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 fallbackSandbox = new AtomicReference<>(); public SandboxBackedFilesystem() { this.fsId = "sandbox-" + UUID.randomUUID().toString().substring(0, 8); @@ -52,12 +61,21 @@ public SandboxBackedFilesystem() { @Override public void setSandbox(Sandbox sandbox) { - this.sandbox = sandbox; + this.fallbackSandbox.set(sandbox); } @Override public Sandbox getSandbox() { - return sandbox; + return fallbackSandbox.get(); + } + + /** + * Clears the fallback slot only if it still holds {@code expected}. Used by the lifecycle + * middleware on release so a finishing call never wipes a sandbox injected by a newer + * concurrent call. + */ + public void clearSandbox(Sandbox expected) { + fallbackSandbox.compareAndSet(expected, null); } @Override @@ -68,7 +86,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( @@ -91,7 +109,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) { @@ -147,7 +165,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) { @@ -192,8 +210,17 @@ public List downloadFiles( return results; } - private Sandbox requireSandbox() { - Sandbox s = sandbox; + private Sandbox requireSandbox(RuntimeContext runtimeContext) { + // Per-call binding first: each in-flight call sees the sandbox bound to its own context, + // so concurrent calls on the same agent instance never observe each other's sandbox. + if (runtimeContext != null) { + Sandbox bound = runtimeContext.get(Sandbox.class); + if (bound != null) { + return bound; + } + } + // Fallback for context-less internal paths (RuntimeContext.empty() maintenance writes). + Sandbox s = fallbackSandbox.get(); if (s == null) { throw new SandboxException.SandboxConfigurationException( "No active sandbox — sandbox filesystem used outside of a call context"); 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 a33ff6edc..e9f51340f 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,6 @@ 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.function.Consumer; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -34,7 +33,8 @@ *

  • Read {@link SandboxContext} from the current {@link RuntimeContext}
  • *
  • Acquire a session via {@link SandboxManager}
  • *
  • Start the session (4-branch workspace init)
  • - *
  • Inject the live session into the {@link SandboxBackedFilesystem} proxy
  • + *
  • Bind the live session to the per-call {@link RuntimeContext} (and mirror it into the + * {@link SandboxBackedFilesystem} fallback slot for context-less internal paths)
  • * * *

    doFinally

    @@ -42,9 +42,13 @@ *
  • Persist sandbox session state via {@link SandboxManager} and * {@link io.agentscope.harness.agent.sandbox.SessionSandboxStateStore}
  • *
  • Release the session via {@link SandboxManager} (stop + optional shutdown)
  • - *
  • Clear the session reference from the filesystem proxy
  • + *
  • Unbind the session from the per-call context and CAS-clear the fallback slot
  • * * + *

    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 currentAcquireResult = - new AtomicReference<>(); private volatile Consumer beforeStartCallback; public SandboxLifecycleMiddleware( @@ -108,13 +110,19 @@ public void acquireForCall(RuntimeContext ctx) { Sandbox sandbox = result.getSandbox(); try { sandbox.start(); + // Per-call binding: the filesystem proxy resolves the sandbox from the + // RuntimeContext, so concurrent calls each see their own sandbox. The proxy's + // fallback slot is mirrored only for context-less internal paths. + ctx.put(Sandbox.class, sandbox); + ctx.put(SandboxAcquireResult.class, result); filesystemProxy.setSandbox(sandbox); - currentAcquireResult.set(result); log.debug( "[sandbox-mw] Acquired sandbox {}", sandbox.getState() != null ? sandbox.getState().getSessionId() : "?"); } catch (Exception e) { - filesystemProxy.setSandbox(null); + ctx.put(Sandbox.class, null); + ctx.put(SandboxAcquireResult.class, null); + filesystemProxy.clearSandbox(sandbox); try { sandboxManager.release(result); } catch (Exception releaseErr) { @@ -136,14 +144,22 @@ public void acquireForCall(RuntimeContext ctx) { * Releases the sandbox after the current call. Called from * {@code ReActAgent.afterAgentExecution()} to ensure cleanup for both paths. * + *

    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 Deque toAcquire = new ArrayDeque<>(); + private final List released = new ArrayList<>(); + + private RecordingSandboxManager() { + super( + new StubClient(), + new SessionSandboxStateStore(new InMemoryAgentStateStore(), "test-agent"), + "test-agent"); + } + + @Override + public SandboxAcquireResult acquire( + SandboxContext sandboxContext, RuntimeContext runtimeContext) { + return SandboxAcquireResult.selfManaged(toAcquire.pop()); + } + + @Override + public void persistState( + SandboxAcquireResult result, + SandboxContext sandboxContext, + RuntimeContext runtimeContext) { + // no-op: pairing is asserted via release() + } + + @Override + public void release(SandboxAcquireResult result) { + released.add(result); + } + } + + private static final class StubSandbox implements Sandbox { + + private final String name; + private String lastCommand; + + private StubSandbox(String name) { + this.name = name; + } + + @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 runtimeContext, String command, Integer timeoutSeconds) { + this.lastCommand = command; + return new ExecResult(0, name, "", false); + } + + @Override + public InputStream persistWorkspace() { + return InputStream.nullInputStream(); + } + + @Override + public void hydrateWorkspace(InputStream archive) {} + } + + private static final class StubClient implements SandboxClient { + + @Override + public Sandbox create( + WorkspaceSpec workspaceSpec, + SandboxSnapshotSpec snapshotSpec, + SandboxClientOptions options) { + throw new UnsupportedOperationException("not used in this test"); + } + + @Override + public Sandbox resume(SandboxState state) { + throw new UnsupportedOperationException("not used in this test"); + } + + @Override + public void delete(Sandbox sandbox) {} + + @Override + public String serializeState(SandboxState state) { + return "{}"; + } + + @Override + public SandboxState deserializeState(String json) { + throw new UnsupportedOperationException("not used in this test"); + } + } +}