Skip to content
Open
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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 ----
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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}.
*
* <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-call sandbox bindings; key is "userId/sessionId" (aligned with ReActAgent.slotKey)
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 +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(
Expand All @@ -90,7 +98,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 +142,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 +176,14 @@ public List<FileDownloadResponse> 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;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -54,9 +55,9 @@ public class SandboxLifecycleMiddleware implements HarnessRuntimeMiddleware {

private final SandboxManager sandboxManager;
private final SandboxBackedFilesystem filesystemProxy;
private final AtomicReference<SandboxAcquireResult> currentAcquireResult =
new AtomicReference<>();
private volatile Consumer<RuntimeContext> beforeStartCallback;
private final ConcurrentHashMap<String, SandboxAcquireResult> acquireResults =
new ConcurrentHashMap<>();

public SandboxLifecycleMiddleware(
SandboxManager sandboxManager, SandboxBackedFilesystem filesystemProxy) {
Expand All @@ -77,11 +78,13 @@ public void setBeforeStartCallback(Consumer<RuntimeContext> 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)
* <p>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 <b>not</b> 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) {
Expand All @@ -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}.
*
* <p>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<RuntimeContext> cb = beforeStartCallback;
if (cb != null) {
Expand All @@ -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) {
Expand All @@ -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)
* <p>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) {
Expand All @@ -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);
}
}
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
@@ -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.
*
* <p>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;
}
}
Loading
Loading