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 958d1382e1..02654dccc6 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 @@ -200,6 +200,12 @@ public List 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() diff --git a/agentscope-harness/src/main/java/io/agentscope/harness/agent/sandbox/impl/docker/DockerSandbox.java b/agentscope-harness/src/main/java/io/agentscope/harness/agent/sandbox/impl/docker/DockerSandbox.java index 730ac0b176..dd70ecf471 100644 --- a/agentscope-harness/src/main/java/io/agentscope/harness/agent/sandbox/impl/docker/DockerSandbox.java +++ b/agentscope-harness/src/main/java/io/agentscope/harness/agent/sandbox/impl/docker/DockerSandbox.java @@ -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; @@ -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; @@ -62,7 +65,7 @@ *
  • HydrateWorkspace: {@code docker exec -i tar -xf - -C }
  • * */ -public class DockerSandbox extends AbstractBaseSandbox { +public class DockerSandbox extends AbstractBaseSandbox implements SandboxFileTransfer { private static final Logger log = LoggerFactory.getLogger(DockerSandbox.class); @@ -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 // ----------------------------------------------------------------- @@ -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(); 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 fc1f1d55fb..ca9e07d3e7 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 @@ -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 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(); diff --git a/agentscope-harness/src/test/java/io/agentscope/harness/agent/sandbox/impl/docker/DockerSandboxFileTransferIntegrationTest.java b/agentscope-harness/src/test/java/io/agentscope/harness/agent/sandbox/impl/docker/DockerSandboxFileTransferIntegrationTest.java new file mode 100644 index 0000000000..d3731b3a87 --- /dev/null +++ b/agentscope-harness/src/test/java/io/agentscope/harness/agent/sandbox/impl/docker/DockerSandboxFileTransferIntegrationTest.java @@ -0,0 +1,224 @@ +/* + * 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.assertArrayEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import io.agentscope.harness.agent.sandbox.WorkspaceSpec; +import java.nio.file.DirectoryStream; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Random; +import java.util.stream.StreamSupport; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Assumptions; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.condition.EnabledIfSystemProperty; + +/** + * Real end-to-end round-trip of {@code docker cp} based file transfer against a live Docker + * daemon. + * + *

    These tests create a short-lived alpine container and prove that a binary blob larger than + * 1 MB survives upload → download bit-for-bit, and that path handling (spaces, relative and + * absolute forms) behaves correctly. They require a reachable Docker daemon. + * + *

    Gated by the {@code -Ddocker.it=true} system property so CI without a usable Docker + * environment stays green by default: + * + *

    {@code
    + * mvn -pl agentscope-harness -Ddocker.it=true -Dtest=DockerSandboxFileTransferIntegrationTest test
    + * }
    + */ +@EnabledIfSystemProperty(named = "docker.it", matches = "true") +class DockerSandboxFileTransferIntegrationTest { + + /** Image must provide {@code tar} and a running shell; alpine is small and sufficient. */ + private static final String TEST_IMAGE = "alpine:latest"; + + private DockerSandbox sandbox; + + @BeforeEach + void setUp() throws Exception { + Assumptions.assumeTrue(dockerDaemonReachable(), "Docker daemon is not reachable"); + + DockerSandboxState state = new DockerSandboxState(); + state.setSessionId("it-" + Long.toHexString(System.nanoTime())); + state.setContainerId(""); // empty → force a fresh container on start() + state.setWorkspaceRoot("/workspace"); + state.setImage(TEST_IMAGE); + state.setContainerOwned(true); + state.setWorkspaceSpec(new WorkspaceSpec()); + + sandbox = new DockerSandbox(state); + sandbox.start(); + } + + @AfterEach + void tearDown() throws Exception { + if (sandbox != null) { + sandbox.shutdown(); + } + } + + @Test + void uploadsAndDownloadsBinaryLargerThanOneMegabyteBitForBit() throws Exception { + // 1.25 MiB of pseudo-random bytes — not compressible, exercises raw byte round-trip. + byte[] payload = new byte[1_250_000]; + new Random(42L).nextBytes(payload); + + String path = "/workspace/blob/roundtrip.bin"; + assertTrue(sandbox.supportsFileTransfer(path)); + + sandbox.uploadFile(path, payload); + byte[] downloaded = sandbox.downloadFile(path); + + assertArrayEquals(payload, downloaded, "uploaded and downloaded bytes must be identical"); + } + + @Test + void relativePathCreatesNestedDirectoriesAndRoundTrips() throws Exception { + byte[] payload = "relative-under-workspace".getBytes(); + + // Relative path — resolveContainerPath prefixes the workspace root. + String path = "deep/nested/folder/notes/report.txt"; + sandbox.uploadFile(path, payload); + byte[] downloaded = sandbox.downloadFile(path); + + assertArrayEquals(payload, downloaded); + } + + @Test + void pathWithSpacesAndQuoteCharactersRoundTrips() throws Exception { + byte[] payload = "space and quote handling".getBytes(); + + String path = "/workspace/my report file/with \"quotes\" and spaces.bin"; + sandbox.uploadFile(path, payload); + byte[] downloaded = sandbox.downloadFile(path); + + assertArrayEquals(payload, downloaded); + } + + @Test + void absoluteProjectedPathRoundTrips() throws Exception { + byte[] payload = "absolute path payload".getBytes(); + + String path = "/workspace/top-level.bin"; + sandbox.uploadFile(path, payload); + byte[] downloaded = sandbox.downloadFile(path); + + assertArrayEquals(payload, downloaded); + } + + @Test + void uploadWithLeadingDotSlashIsAccepted() throws Exception { + byte[] payload = "dot-slash prefix".getBytes(); + + // resolveContainerPath strips leading "./" segments. + String path = "./relative/dot-file.txt"; + sandbox.uploadFile(path, payload); + byte[] downloaded = sandbox.downloadFile(path); + + assertArrayEquals(payload, downloaded); + } + + @Test + void downloadOfMissingFileFailsWithSandboxRuntimeException() { + // docker cp of a nonexistent source exits non-zero → runDockerCliBlocking throws. + assertThrows(Exception.class, () -> sandbox.downloadFile("/workspace/does-not-exist.bin")); + } + + @Test + void transfersLeaveNoTempFilesBehind() throws Exception { + Path tempDir = Path.of(System.getProperty("java.io.tmpdir")); + byte[] payload = "cleanup check".getBytes(); + + // Successful upload + download, plus a failing download (non-zero docker exit) must + // all clean up their scratch files via the finally blocks. + sandbox.uploadFile("/workspace/cleanup/ok.bin", payload); + sandbox.downloadFile("/workspace/cleanup/ok.bin"); + assertThrows(Exception.class, () -> sandbox.downloadFile("/workspace/cleanup/missing.bin")); + + try (DirectoryStream stream = Files.newDirectoryStream(tempDir)) { + boolean leftover = + StreamSupport.stream(stream.spliterator(), false) + .anyMatch( + p -> { + String name = p.getFileName().toString(); + return name.startsWith("agentscope-docker-upload-") + || name.startsWith("agentscope-docker-download-"); + }); + assertFalse(leftover, "docker cp scratch files must be removed"); + } + } + + @Test + void supportsFileTransferRejectsRootAndOutsideWorkspace() { + assertFalse(sandbox.supportsFileTransfer("/workspace")); + assertFalse(sandbox.supportsFileTransfer("/workspace/")); + assertFalse(sandbox.supportsFileTransfer("/etc/passwd")); + assertFalse(sandbox.supportsFileTransfer("/workspace/../etc/passwd")); + assertTrue(sandbox.supportsFileTransfer("/workspace/a.txt")); + assertTrue(sandbox.supportsFileTransfer("a.txt")); + } + + @Test + void workspaceRootSlashAllowsWholeFilesystem() throws Exception { + // A second sandbox whose workspace root is "/" — every path is in scope. + DockerSandbox other = null; + try { + DockerSandboxState rootState = new DockerSandboxState(); + rootState.setSessionId("it-root-" + Long.toHexString(System.nanoTime())); + rootState.setContainerId(""); + rootState.setWorkspaceRoot("/"); + rootState.setImage(TEST_IMAGE); + rootState.setContainerOwned(true); + rootState.setWorkspaceSpec(new WorkspaceSpec()); + + other = new DockerSandbox(rootState); + other.start(); + + String path = "/tmp/root-workspace-check.bin"; + assertTrue(other.supportsFileTransfer(path), "root workspace must accept /tmp path"); + byte[] payload = "root workspace".getBytes(); + other.uploadFile(path, payload); + assertArrayEquals(payload, other.downloadFile(path)); + } finally { + if (other != null) { + other.shutdown(); + } + } + } + + /** Whether the current test process can reach a live Docker daemon. */ + private static boolean dockerDaemonReachable() { + try { + Process process = + new ProcessBuilder("docker", "version", "--format", "{{.Server.Version}}") + .inheritIO() + .redirectErrorStream(true) + .start(); + boolean exited = process.waitFor(15, java.util.concurrent.TimeUnit.SECONDS); + return exited && process.exitValue() == 0; + } catch (Exception e) { + return false; + } + } +} diff --git a/agentscope-harness/src/test/java/io/agentscope/harness/agent/sandbox/impl/docker/DockerSandboxFileTransferTest.java b/agentscope-harness/src/test/java/io/agentscope/harness/agent/sandbox/impl/docker/DockerSandboxFileTransferTest.java new file mode 100644 index 0000000000..61f112221a --- /dev/null +++ b/agentscope-harness/src/test/java/io/agentscope/harness/agent/sandbox/impl/docker/DockerSandboxFileTransferTest.java @@ -0,0 +1,271 @@ +/* + * 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.assertArrayEquals; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import io.agentscope.harness.agent.sandbox.WorkspaceSpec; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.List; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +class DockerSandboxFileTransferTest { + + private DockerSandbox sandbox; + + @BeforeEach + void setUp() { + DockerSandboxState state = new DockerSandboxState(); + state.setContainerId("container-1"); + state.setWorkspaceRoot("/workspace"); + state.setWorkspaceSpec(new WorkspaceSpec()); + sandbox = new DockerSandbox(state); + } + + @Test + void supportsRelativeAndAbsolutePathsUnderWorkspace() { + assertTrue(sandbox.supportsFileTransfer("notes/report.bin")); + assertTrue(sandbox.supportsFileTransfer("/workspace/notes/report.bin")); + assertFalse(sandbox.supportsFileTransfer("/workspace")); + assertFalse(sandbox.supportsFileTransfer("/etc/passwd")); + } + + @Test + void rejectsTraversalAndUnavailableContainer() { + assertFalse(sandbox.supportsFileTransfer("/workspace/../etc/passwd")); + assertFalse(sandbox.supportsFileTransfer("/workspace/..")); + assertFalse(sandbox.supportsFileTransfer("/workspace/../.")); + + DockerSandboxState state = new DockerSandboxState(); + state.setWorkspaceRoot("/workspace"); + state.setWorkspaceSpec(new WorkspaceSpec()); + assertFalse(new DockerSandbox(state).supportsFileTransfer("/workspace/a.txt")); + } + + @Test + void uploadAndDownloadRejectPathsOutsideWorkspaceWithoutTouchingDocker() { + DockerSandbox noTouch = new DockerSandbox(stateWithWorkspace("/workspace", "container-1")); + assertThrows( + IllegalArgumentException.class, + () -> noTouch.uploadFile("/etc/cron.d", new byte[] {1})); + assertThrows( + IllegalArgumentException.class, () -> noTouch.downloadFile("/workspace/../secret")); + assertThrows( + IllegalArgumentException.class, + () -> noTouch.uploadFile("/workspace", new byte[] {1})); + assertThrows(IllegalArgumentException.class, () -> noTouch.downloadFile("/workspace")); + } + + @Test + void uploadRejectsNullContentEvenWhenContainerUnavailable() { + DockerSandbox noContainer = new DockerSandbox(stateWithWorkspace("/workspace", null)); + assertThrows( + IllegalArgumentException.class, + () -> noContainer.uploadFile("/workspace/a.bin", null), + "null content must be rejected before the container check"); + } + + @Test + void uploadAndDownloadRejectBlankAndNullPaths() { + assertThrows(IllegalArgumentException.class, () -> sandbox.uploadFile("", new byte[] {1})); + assertThrows( + IllegalArgumentException.class, () -> sandbox.uploadFile(" ", new byte[] {1})); + assertThrows( + IllegalArgumentException.class, () -> sandbox.uploadFile(null, new byte[] {1})); + assertThrows(IllegalArgumentException.class, () -> sandbox.downloadFile("")); + assertThrows(IllegalArgumentException.class, () -> sandbox.downloadFile(null)); + } + + @Test + void supportsWorkspaceRootOfSlash() { + DockerSandbox rootSandbox = new DockerSandbox(stateWithWorkspace("/", "container-root")); + assertTrue(rootSandbox.supportsFileTransfer("/tmp/a.bin")); + assertTrue(rootSandbox.supportsFileTransfer("/workspace/x.txt")); + assertTrue(rootSandbox.supportsFileTransfer("relative.txt")); + assertFalse(rootSandbox.supportsFileTransfer("/")); + // The whole filesystem is in scope when the workspace root is "/". + } + + @Test + void supportsPathsWithSpacesAndQuotes() { + assertTrue(sandbox.supportsFileTransfer("/workspace/my file.bin")); + assertTrue(sandbox.supportsFileTransfer("/workspace/with\"quote\".bin")); + assertTrue(sandbox.supportsFileTransfer("/workspace/a b/c \"d\"/e.txt")); + assertTrue(sandbox.supportsFileTransfer("relative name with spaces.txt")); + } + + @Test + void whitespaceAndDotDotSegmentsInsideWorkspaceAreRejected() { + assertFalse(sandbox.supportsFileTransfer("/workspace/a/../b")); + assertFalse(sandbox.supportsFileTransfer("/workspace/a/./b")); + assertFalse(sandbox.supportsFileTransfer("/workspace//b")); + } + + @Test + void supportsFileTransferNeverLeaksStateAcrossInstances() { + // The no-container sandbox must not fall back to a previously seen container. + DockerSandbox noContainer = new DockerSandbox(stateWithWorkspace("/workspace", null)); + assertFalse(noContainer.supportsFileTransfer("/workspace/a.txt")); + assertThrows( + IllegalArgumentException.class, + () -> noContainer.uploadFile("/workspace/a.txt", new byte[] {1})); + } + + @Test + void uploadWritesContentThroughDockerCpAndCleansUpTemp() throws Exception { + RecordingDockerSandbox recording = + new RecordingDockerSandbox(stateWithWorkspace("/workspace", "container-1")); + byte[] content = new byte[] {1, 2, 3, 4, 5}; + + recording.uploadFile("notes/report.bin", content); + + // The file bytes reached docker cp intact (never through exec argv). + assertArrayEquals(content, recording.uploadedContent); + // Parent directory is created, then the temp file is copied to the resolved path. + assertTrue( + recording.commands.contains( + List.of( + "docker", + "exec", + "container-1", + "mkdir", + "-p", + "/workspace/notes")), + recording.commands.toString()); + List cp = recording.lastCpCommand(); + assertNotNull(cp); + assertEquals("container-1:/workspace/notes/report.bin", cp.get(3)); + // Host temp file is removed after the round trip. + assertNotNull(recording.lastCpSource); + assertFalse(Files.exists(Path.of(recording.lastCpSource))); + } + + @Test + void uploadAtWorkspaceRootUsesSlashParent() throws Exception { + RecordingDockerSandbox recording = + new RecordingDockerSandbox(stateWithWorkspace("/", "container-root")); + + recording.uploadFile("/file.bin", new byte[] {9}); + + assertTrue( + recording.commands.contains( + List.of("docker", "exec", "container-root", "mkdir", "-p", "/")), + recording.commands.toString()); + assertEquals("container-root:/file.bin", recording.lastCpCommand().get(3)); + } + + @Test + void downloadReadsContentThroughDockerCpAndCleansUpTemp() throws Exception { + RecordingDockerSandbox recording = + new RecordingDockerSandbox(stateWithWorkspace("/workspace", "container-1")); + recording.downloadPayload = new byte[] {7, 8, 9}; + + byte[] out = recording.downloadFile("/workspace/out.bin"); + + assertArrayEquals(recording.downloadPayload, out); + List cp = recording.lastCpCommand(); + assertEquals("container-1:/workspace/out.bin", cp.get(2)); + // Destination temp file is removed after the bytes are read back. + assertNotNull(recording.lastCpDest); + assertFalse(Files.exists(Path.of(recording.lastCpDest))); + } + + @Test + void uploadCleansUpTempFileWhenDockerCpFails() { + RecordingDockerSandbox recording = + new RecordingDockerSandbox(stateWithWorkspace("/workspace", "container-1")); + recording.failCp = true; + + assertThrows( + RuntimeException.class, + () -> recording.uploadFile("/workspace/a.bin", new byte[] {1})); + + assertNotNull(recording.lastCpSource); + assertFalse(Files.exists(Path.of(recording.lastCpSource))); + } + + private static DockerSandboxState stateWithWorkspace(String root, String containerId) { + DockerSandboxState state = new DockerSandboxState(); + state.setWorkspaceRoot(root); + state.setContainerId(containerId); + state.setWorkspaceSpec(new WorkspaceSpec()); + return state; + } + + /** + * Intercepts the docker CLI so the upload/download temp-file plumbing runs end to end without a + * live Docker daemon: {@code docker cp} to a container captures the source bytes, and {@code + * docker cp} from a container writes {@link #downloadPayload} into the host destination. + */ + private static final class RecordingDockerSandbox extends DockerSandbox { + + final List> commands = new ArrayList<>(); + byte[] uploadedContent; + byte[] downloadPayload; + String lastCpSource; + String lastCpDest; + boolean failCp; + + RecordingDockerSandbox(DockerSandboxState state) { + super(state); + } + + List lastCpCommand() { + for (int i = commands.size() - 1; i >= 0; i--) { + List c = commands.get(i); + if (c.size() >= 2 && "cp".equals(c.get(1))) { + return c; + } + } + return null; + } + + @Override + protected void runDockerCliBlocking(int timeoutSeconds, String... command) + throws Exception { + commands.add(List.of(command)); + if (command.length >= 4 && "cp".equals(command[1])) { + String src = command[2]; + String dst = command[3]; + // Record paths before any simulated failure so cleanup can be asserted. + if (dst.contains(":")) { + lastCpSource = src; + } else { + lastCpDest = dst; + } + if (failCp) { + throw new RuntimeException("simulated docker cp failure"); + } + if (dst.contains(":")) { + // upload: host temp -> container:path + uploadedContent = Files.readAllBytes(Path.of(src)); + } else { + // download: container:path -> host temp + Files.write( + Path.of(dst), downloadPayload == null ? new byte[0] : downloadPayload); + } + } + } + } +}