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 @@ -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 Down Expand Up @@ -62,14 +63,15 @@
* <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);

private static final int OUTPUT_TRUNCATE_BYTES = 512 * 1024; // 512 KB per stream
private static final int CONTAINER_START_TIMEOUT_SECONDS = 60;
private static final int CONTAINER_STOP_TIMEOUT_SECONDS = 30;
private static final int TAR_TIMEOUT_SECONDS = 120;
private static final int FILE_TRANSFER_TIMEOUT_SECONDS = 120;

private final DockerSandboxState dockerState;

Expand Down Expand Up @@ -190,6 +192,131 @@ protected ExecResult doExec(RuntimeContext runtimeContext, String command, int t
return result;
}

@Override
public boolean supportsFileTransfer(String path) {
return path != null && !path.isBlank() && path.indexOf('\0') < 0;
}

@Override
public void uploadFile(String path, byte[] content) throws Exception {
requireTransferPath(path);
String containerId = requireContainerId();
String workspaceRoot = dockerState.getWorkspaceRoot();
int lastSlash = path.lastIndexOf('/');
if (lastSlash >= 0) {
String parent = lastSlash == 0 ? "/" : path.substring(0, lastSlash);
runDockerCliBlocking(
30,
"docker",
"exec",
"-w",
workspaceRoot,
containerId,
"mkdir",
"-p",
"--",
parent);
}

Process process =
new ProcessBuilder(
"docker",
"exec",
"-i",
"-w",
workspaceRoot,
containerId,
"sh",
"-c",
"cat > \"$1\"",
"sh",
path)
.start();

ExecutorService ioExecutor = newFileTransferExecutor("upload", 3);
Future<?> writeFuture =
ioExecutor.submit(
() -> {
try (OutputStream stdin = process.getOutputStream()) {
stdin.write(content);
}
return null;
});
Future<String> stdoutFuture =
ioExecutor.submit(
() -> readStream(process.getInputStream(), OUTPUT_TRUNCATE_BYTES));
Future<String> stderrFuture =
ioExecutor.submit(
() -> readStream(process.getErrorStream(), OUTPUT_TRUNCATE_BYTES));
ioExecutor.shutdown();

try {
writeFuture.get(FILE_TRANSFER_TIMEOUT_SECONDS, TimeUnit.SECONDS);
if (!process.waitFor(FILE_TRANSFER_TIMEOUT_SECONDS, TimeUnit.SECONDS)) {
throw new IOException("docker file upload timed out: " + path);
}

String stdout = stdoutFuture.get();
String stderr = stderrFuture.get();
if (process.exitValue() != 0) {
String detail = stderr.isBlank() ? stdout : stderr;
throw new IOException(
"docker file upload failed (exit=" + process.exitValue() + "): " + detail);
}
} finally {
if (process.isAlive()) {
process.destroyForcibly();
}
ioExecutor.shutdownNow();
}
}

@Override
public byte[] downloadFile(String path) throws Exception {
requireTransferPath(path);
String containerId = requireContainerId();
Process process =
new ProcessBuilder(
"docker",
"exec",
"-w",
dockerState.getWorkspaceRoot(),
containerId,
"cat",
"--",
path)
.start();

ExecutorService ioExecutor = newFileTransferExecutor("download", 2);
Future<byte[]> stdoutFuture =
ioExecutor.submit(() -> process.getInputStream().readAllBytes());
Future<String> stderrFuture =
ioExecutor.submit(
() -> readStream(process.getErrorStream(), OUTPUT_TRUNCATE_BYTES));
ioExecutor.shutdown();

try {
if (!process.waitFor(FILE_TRANSFER_TIMEOUT_SECONDS, TimeUnit.SECONDS)) {
throw new IOException("docker file download timed out: " + path);
}
byte[] content = stdoutFuture.get();
String stderr = stderrFuture.get();
if (process.exitValue() != 0) {
throw new IOException(
"docker file download failed (exit="
+ process.exitValue()
+ "): "
+ stderr);
}
return content;
} finally {
if (process.isAlive()) {
process.destroyForcibly();
}
ioExecutor.shutdownNow();
}
}

@Override
protected InputStream doPersistWorkspace() throws Exception {
String containerId = dockerState.getContainerId();
Expand Down Expand Up @@ -611,6 +738,36 @@ private void runDockerCliBlocking(int timeoutSeconds, String... command) throws
}
}

private String requireContainerId() {
String containerId = dockerState.getContainerId();
if (containerId == null || containerId.isBlank()) {
throw new IllegalStateException("Docker sandbox has no active container");
}
return containerId;
}

private void requireTransferPath(String path) {
if (!supportsFileTransfer(path)) {
throw new IllegalArgumentException("Invalid sandbox file path: " + path);
}
}

private ExecutorService newFileTransferExecutor(String operation, int threadCount) {
return Executors.newFixedThreadPool(
threadCount,
r -> {
Thread t =
new Thread(
r,
"sandbox-docker-"
+ operation
+ "-"
+ dockerState.getSessionId());
t.setDaemon(true);
return t;
});
}

/**
* Reads an InputStream into a String, truncating at {@code maxBytes}.
*/
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
/*
* 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.impl.docker;

import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertInstanceOf;
import static org.junit.jupiter.api.Assertions.assertTrue;

import io.agentscope.harness.agent.sandbox.SandboxFileTransfer;
import io.agentscope.harness.agent.sandbox.WorkspaceSpec;
import org.junit.jupiter.api.Test;

class DockerSandboxFileTransferTest {

@Test
void supportsNativeTransferForRelativeAndAbsolutePaths() {
DockerSandboxState state = new DockerSandboxState();
state.setWorkspaceSpec(new WorkspaceSpec());
DockerSandbox sandbox = new DockerSandbox(state);

assertInstanceOf(SandboxFileTransfer.class, sandbox);
assertTrue(sandbox.supportsFileTransfer("agents/session.jsonl"));
assertTrue(sandbox.supportsFileTransfer("/workspace/session.jsonl"));
assertFalse(sandbox.supportsFileTransfer(null));
assertFalse(sandbox.supportsFileTransfer(""));
}
}
Loading