Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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 ----
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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}.
*
* <p>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}.
* <p>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<String, Sandbox> 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
Expand All @@ -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(
Expand All @@ -90,7 +97,7 @@ public ExecuteResponse execute(
@Override
public List<FileUploadResponse> uploadFiles(
RuntimeContext runtimeContext, List<Map.Entry<String, byte[]>> files) {
Sandbox active = requireSandbox();
Sandbox active = requireSandbox(runtimeContext);
List<FileUploadResponse> results = new ArrayList<>(files.size());

for (Map.Entry<String, byte[]> file : files) {
Expand Down Expand Up @@ -134,7 +141,7 @@ public List<FileUploadResponse> uploadFiles(
@Override
public List<FileDownloadResponse> downloadFiles(
RuntimeContext runtimeContext, List<String> paths) {
Sandbox active = requireSandbox();
Sandbox active = requireSandbox(runtimeContext);
List<FileDownloadResponse> results = new ArrayList<>(paths.size());

for (String path : paths) {
Expand Down Expand Up @@ -168,11 +175,14 @@ public List<FileDownloadResponse> 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;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -53,8 +53,9 @@ public class SandboxLifecycleMiddleware implements HarnessRuntimeMiddleware {

private final SandboxManager sandboxManager;
private final SandboxBackedFilesystem filesystemProxy;
private final AtomicReference<SandboxAcquireResult> currentAcquireResult =
new AtomicReference<>();
// per-session acquire results; keyed by sessionId so concurrent sessions don't interfere
private final ConcurrentHashMap<String, SandboxAcquireResult> acquireResults =
new ConcurrentHashMap<>();

public SandboxLifecycleMiddleware(
SandboxManager sandboxManager, SandboxBackedFilesystem filesystemProxy) {
Expand All @@ -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) {
Expand All @@ -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) {
Expand All @@ -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);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -21,12 +21,16 @@
* Marks a filesystem that can have its backing {@link Sandbox} injected at runtime.
*
* <p>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);
}
Original file line number Diff line number Diff line change
Expand Up @@ -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<FileDownloadResponse> 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());
Expand All @@ -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<FileDownloadResponse> 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());
Expand All @@ -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<FileDownloadResponse> 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());
Expand All @@ -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;
Expand Down
Loading