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 @@ -200,6 +200,12 @@ public List<FileDownloadResponse> downloadFiles(

ExecResult result = active.exec(runtimeContext, cmd, null);
if (result.ok()) {
if (result.truncated()) {
results.add(
FileDownloadResponse.fail(
path, "File download output was truncated by the sandbox"));
continue;
}
// MIME decoder tolerates wrapped base64 output from GNU `base64`.
byte[] decoded =
Base64.getMimeDecoder()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
import io.agentscope.harness.agent.sandbox.ExecResult;
import io.agentscope.harness.agent.sandbox.SandboxErrorCode;
import io.agentscope.harness.agent.sandbox.SandboxException;
import io.agentscope.harness.agent.sandbox.SandboxFileTransfer;
import io.agentscope.harness.agent.sandbox.WorkspaceMountSupport;
import io.agentscope.harness.agent.sandbox.layout.BindMountEntry;
import io.agentscope.harness.agent.sandbox.layout.WorkspaceEntry;
Expand All @@ -29,6 +30,8 @@
import java.io.InputStream;
import java.io.OutputStream;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
Expand Down Expand Up @@ -62,7 +65,7 @@
* <li>HydrateWorkspace: {@code docker exec -i <containerId> tar -xf - -C <root>}</li>
* </ul>
*/
public class DockerSandbox extends AbstractBaseSandbox {
public class DockerSandbox extends AbstractBaseSandbox implements SandboxFileTransfer {

private static final Logger log = LoggerFactory.getLogger(DockerSandbox.class);

Expand Down Expand Up @@ -352,6 +355,115 @@ protected String getWorkspaceRoot() {
return dockerState.getWorkspaceRoot();
}

@Override
public boolean supportsFileTransfer(String path) {
if (dockerState.getContainerId() == null || dockerState.getContainerId().isBlank()) {
return false;
}
try {
String resolved = resolveContainerPath(path);
String root = normalizeAbsolutePath(dockerState.getWorkspaceRoot());
return root != null
&& !resolved.equals(root)
&& resolved.startsWith("/".equals(root) ? "/" : root + "/");
} catch (IllegalArgumentException e) {
return false;
}
}

@Override
public void uploadFile(String path, byte[] content) throws Exception {
if (content == null) {
throw new IllegalArgumentException("File content must not be null");
}
String containerPath = requireTransferPath(path);
Path temp = Files.createTempFile("agentscope-docker-upload-", ".bin");
try {
Files.write(temp, content);
int slash = containerPath.lastIndexOf('/');
String parent = slash > 0 ? containerPath.substring(0, slash) : "/";
runDockerCliBlocking(
30, "docker", "exec", dockerState.getContainerId(), "mkdir", "-p", parent);
runDockerCliBlocking(
TAR_TIMEOUT_SECONDS,
"docker",
"cp",
temp.toString(),
dockerState.getContainerId() + ":" + containerPath);
} finally {
Files.deleteIfExists(temp);
}
}

@Override
public byte[] downloadFile(String path) throws Exception {
String containerPath = requireTransferPath(path);
Path temp = Files.createTempFile("agentscope-docker-download-", ".bin");
try {
runDockerCliBlocking(
TAR_TIMEOUT_SECONDS,
"docker",
"cp",
dockerState.getContainerId() + ":" + containerPath,
temp.toString());
return Files.readAllBytes(temp);
} finally {
Files.deleteIfExists(temp);
}
}

private String requireTransferPath(String path) {
if (dockerState.getContainerId() == null || dockerState.getContainerId().isBlank()) {
throw new IllegalArgumentException("Docker container is unavailable");
}
String resolved = resolveContainerPath(path);
String root = normalizeAbsolutePath(dockerState.getWorkspaceRoot());
if (root == null
|| resolved.equals(root)
|| !resolved.startsWith("/".equals(root) ? "/" : root + "/")) {
throw new IllegalArgumentException("Path is outside the sandbox workspace: " + path);
}
return resolved;
}

private String resolveContainerPath(String path) {
if (path == null || path.isBlank()) {
throw new IllegalArgumentException("Path must identify a file");
}
String normalized = path.replace('\\', '/');
while (normalized.startsWith("./")) {
normalized = normalized.substring(2);
}
String[] parts = normalized.split("/", -1);
for (int i = 0; i < parts.length; i++) {
String part = parts[i];
if ((i > 0 || !normalized.startsWith("/")) && part.isEmpty()
|| ".".equals(part)
|| "..".equals(part)) {
throw new IllegalArgumentException("Path contains traversal segments: " + path);
}
}
if (path.startsWith("/") || normalized.startsWith("/")) {
return normalizeAbsolutePath(normalized);
}
String root = normalizeAbsolutePath(dockerState.getWorkspaceRoot());
if (root == null) {
throw new IllegalArgumentException("Sandbox workspace root is unavailable");
}
return "/".equals(root) ? "/" + normalized : root + "/" + normalized;
}

private static String normalizeAbsolutePath(String path) {
if (path == null || path.isBlank() || !path.startsWith("/")) {
return null;
}
String normalized = path.replace('\\', '/');
while (normalized.endsWith("/") && normalized.length() > 1) {
normalized = normalized.substring(0, normalized.length() - 1);
}
return normalized;
}

// -----------------------------------------------------------------
// Container management
// -----------------------------------------------------------------
Expand Down Expand Up @@ -581,7 +693,9 @@ private ContainerState inspectContainerState(String containerId) {
* @param command command and arguments
* @throws SandboxException.SandboxRuntimeException if the command fails or times out
*/
private void runDockerCliBlocking(int timeoutSeconds, String... command) throws Exception {
// Visible for testing so unit tests can intercept the docker CLI round trip
// (upload/download temp-file plumbing) without a live Docker daemon.
protected void runDockerCliBlocking(int timeoutSeconds, String... command) throws Exception {
ProcessBuilder pb = new ProcessBuilder(command);
Process process = pb.start();

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,19 @@ void downloadFiles_returnsFailureWhenCommandFails() {
assertEquals("[stderr] boom", responses.get(0).error());
}

@Test
void downloadFiles_rejectsTruncatedExecOutput() {
SandboxBackedFilesystem filesystem = new SandboxBackedFilesystem();
FakeSandbox sandbox = new FakeSandbox(new ExecResult(0, "partial", "", true));
filesystem.setSandbox(sandbox);

List<FileDownloadResponse> responses =
filesystem.downloadFiles(RT, List.of("/tmp/truncated.bin"));

assertTrue(!responses.get(0).isSuccess());
assertEquals("File download output was truncated by the sandbox", responses.get(0).error());
}

@Test
void uploadFiles_prefersNativeTransferWhenSupported() {
SandboxBackedFilesystem filesystem = new SandboxBackedFilesystem();
Expand Down
Loading
Loading