From 29d1197cbf77a2add1b8d67f69dae369b5f2e035 Mon Sep 17 00:00:00 2001 From: Kamil Tomaszek Date: Wed, 22 Jul 2026 03:23:21 -0700 Subject: [PATCH] fix(codeexecutors): add opt-in strict sandbox to ContainerCodeExecutor PiperOrigin-RevId: 952004237 --- .../codeexecutors/ContainerCodeExecutor.java | 316 +++++++++++++----- .../ContainerCodeExecutorTest.java | 237 +++++++++++++ 2 files changed, 473 insertions(+), 80 deletions(-) create mode 100644 core/src/test/java/com/google/adk/codeexecutors/ContainerCodeExecutorTest.java diff --git a/core/src/main/java/com/google/adk/codeexecutors/ContainerCodeExecutor.java b/core/src/main/java/com/google/adk/codeexecutors/ContainerCodeExecutor.java index a16379455..359e395b7 100644 --- a/core/src/main/java/com/google/adk/codeexecutors/ContainerCodeExecutor.java +++ b/core/src/main/java/com/google/adk/codeexecutors/ContainerCodeExecutor.java @@ -1,5 +1,5 @@ /* - * Copyright 2025 Google LLC + * Copyright 2026 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -19,33 +19,81 @@ import static java.util.Objects.requireNonNullElse; import com.github.dockerjava.api.DockerClient; +import com.github.dockerjava.api.command.CreateContainerResponse; import com.github.dockerjava.api.command.ExecCreateCmdResponse; -import com.github.dockerjava.api.model.Container; +import com.github.dockerjava.api.model.Capability; +import com.github.dockerjava.api.model.HostConfig; import com.github.dockerjava.core.DefaultDockerClientConfig; import com.github.dockerjava.core.DockerClientBuilder; import com.github.dockerjava.core.command.ExecStartResultCallback; import com.google.adk.agents.InvocationContext; import com.google.adk.codeexecutors.CodeExecutionUtils.CodeExecutionInput; import com.google.adk.codeexecutors.CodeExecutionUtils.CodeExecutionResult; +import com.google.common.annotations.VisibleForTesting; +import com.google.common.collect.ImmutableList; +import com.google.common.collect.ImmutableMap; import java.io.ByteArrayOutputStream; import java.io.File; import java.io.IOException; import java.io.UncheckedIOException; import java.nio.charset.StandardCharsets; import java.nio.file.Paths; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; import org.slf4j.Logger; import org.slf4j.LoggerFactory; -/** A code executor that uses a custom container to execute code. */ -public class ContainerCodeExecutor extends BaseCodeExecutor { +/** + * A code executor that runs code in a Docker container. + * + *

A fresh container is created for every {@link #executeCode} call and force-removed afterwards, + * so code from one execution cannot observe or affect another's environment. Code is run via {@code + * docker exec} (as in ADK Python), so the image only needs {@code python3} on its PATH; any image + * {@code ENTRYPOINT} is bypassed. + * + *

Sandboxing is opt-in. By default the execution container is unrestricted (network + * enabled, writable filesystem, no resource or time limits), matching the previous behavior so + * existing callers are not broken; a warning is logged when it is used this way. Call {@link + * #setStrictSandbox(boolean) setStrictSandbox(true)} to harden each container: no network (unless + * re-enabled via {@link #setNetworkEnabled(boolean)}), all Linux capabilities dropped, no privilege + * escalation, a read-only root filesystem with a small writable {@code /tmp} tmpfs, memory/PID + * limits, and a wall-clock execution timeout. Strict sandboxing becomes the default in ADK 2.0. + * + *

The execution timeout and memory limit used by the strict sandbox are configurable via {@link + * #setExecutionTimeoutSeconds(long)} and {@link #setMemoryLimitBytes(long)}. + * + *

This executor holds a {@link DockerClient}; call {@link #close()} (or rely on the registered + * JVM shutdown hook) to release its connections and threads. As with ADK Python, an abrupt JVM + * termination (e.g. SIGKILL) during an execution may leave a container behind. + */ +public class ContainerCodeExecutor extends BaseCodeExecutor implements AutoCloseable { private static final Logger logger = LoggerFactory.getLogger(ContainerCodeExecutor.class); private static final String DEFAULT_IMAGE_TAG = "adk-code-executor:latest"; + /** Default memory limit for each execution container (512 MiB). */ + private static final long DEFAULT_MEMORY_LIMIT_BYTES = 512L * 1024 * 1024; + + /** Maximum number of processes/threads allowed inside an execution container. */ + private static final long PIDS_LIMIT = 128L; + + /** Default max wall-clock time a single execution may run before its container is killed. */ + private static final long DEFAULT_EXECUTION_TIMEOUT_SECONDS = 60L; + private final String baseUrl; private final String image; private final String dockerPath; private final DockerClient dockerClient; - private Container container; + // Registered by the image/dockerPath constructor as a backstop; removed in close() so a closed + // executor is not retained by the JVM's shutdown-hook list. + private final Thread shutdownHook = new Thread(this::close); + private boolean networkEnabled = false; + private long executionTimeoutSeconds = DEFAULT_EXECUTION_TIMEOUT_SECONDS; + private long memoryLimitBytes = DEFAULT_MEMORY_LIMIT_BYTES; + + // Off by default so this executor does not change behavior for existing callers; a warning is + // logged while it is disabled, and it becomes the default in ADK 2.0. + private boolean strictSandbox = false; + private final AtomicBoolean strictSandboxWarningLogged = new AtomicBoolean(false); /** * Creates a ContainerCodeExecutor from an image. @@ -99,17 +147,65 @@ public ContainerCodeExecutor(String baseUrl, String image, String dockerPath) { this.baseUrl = baseUrl; this.image = requireNonNullElse(image, DEFAULT_IMAGE_TAG); this.dockerPath = dockerPath == null ? null : Paths.get(dockerPath).toAbsolutePath().toString(); + this.dockerClient = buildDockerClient(baseUrl); + prepareImage(); + // Backstop so the client is released even if callers forget to close() this executor. + Runtime.getRuntime().addShutdownHook(shutdownHook); + } - if (baseUrl != null) { - var config = - DefaultDockerClientConfig.createDefaultConfigBuilder().withDockerHost(baseUrl).build(); - this.dockerClient = DockerClientBuilder.getInstance(config).build(); - } else { - this.dockerClient = DockerClientBuilder.getInstance().build(); - } + /** Test-only constructor that injects a Docker client and skips image preparation. */ + @VisibleForTesting + ContainerCodeExecutor(DockerClient dockerClient, String image) { + this.baseUrl = null; + this.image = requireNonNullElse(image, DEFAULT_IMAGE_TAG); + this.dockerPath = null; + this.dockerClient = dockerClient; + } + + /** + * Enables or disables container networking when the strict sandbox is on. In strict mode + * networking is disabled by default so executed code cannot reach the network (including the + * cloud metadata endpoint); pass {@code true} to allow it. Has no effect unless {@link + * #setStrictSandbox(boolean)} is enabled — without the sandbox the container always has network + * access. + */ + public ContainerCodeExecutor setNetworkEnabled(boolean networkEnabled) { + this.networkEnabled = networkEnabled; + return this; + } + + /** + * Sets the maximum wall-clock time (in seconds) a single execution may run, in the strict + * sandbox, before its container is force-removed (killed). Defaults to 60 seconds. Has no effect + * unless {@link #setStrictSandbox(boolean)} is enabled. + */ + public ContainerCodeExecutor setExecutionTimeoutSeconds(long executionTimeoutSeconds) { + this.executionTimeoutSeconds = executionTimeoutSeconds; + return this; + } + + /** + * Sets the per-execution container memory limit, in bytes, used by the strict sandbox. Defaults + * to 512 MiB. Has no effect unless {@link #setStrictSandbox(boolean)} is enabled. + */ + public ContainerCodeExecutor setMemoryLimitBytes(long memoryLimitBytes) { + this.memoryLimitBytes = memoryLimitBytes; + return this; + } - initContainer(); - Runtime.getRuntime().addShutdownHook(new Thread(this::cleanupContainer)); + /** + * Enables the strict sandbox. When enabled, each execution runs in a hardened container: no + * network (unless re-enabled via {@link #setNetworkEnabled(boolean)}), all Linux capabilities + * dropped, no privilege escalation, a read-only root filesystem (writable {@code /tmp} only), + * memory/PID limits, and a wall-clock timeout. + * + *

Disabled by default so enabling the sandbox is not a breaking change for existing callers. + * While it is disabled a warning is logged, because running untrusted, model-generated code + * without the sandbox is dangerous. Strict sandboxing becomes the default in ADK 2.0. + */ + public ContainerCodeExecutor setStrictSandbox(boolean strictSandbox) { + this.strictSandbox = strictSandbox; + return this; } @Override @@ -125,70 +221,140 @@ public boolean optimizeDataFile() { @Override public CodeExecutionResult executeCode( InvocationContext invocationContext, CodeExecutionInput codeExecutionInput) { + warnIfStrictSandboxDisabled(); + ByteArrayOutputStream stdout = new ByteArrayOutputStream(); ByteArrayOutputStream stderr = new ByteArrayOutputStream(); - ExecCreateCmdResponse execCreateCmdResponse = - dockerClient - .execCreateCmd(container.getId()) - .withAttachStdout(true) - .withAttachStderr(true) - .withCmd("python3", "-c", codeExecutionInput.code()) - .exec(); + // A fresh container per execution isolates each run from every other session's execution + // environment. Code is run via `docker exec` (as in ADK Python), which needs only `python3` on + // the image and bypasses any ENTRYPOINT. The hardened HostConfig is only applied in the strict + // sandbox; otherwise the container is left unrestricted to preserve existing behavior. + var createContainerCmd = + dockerClient.createContainerCmd(image).withTty(true).withAttachStdin(true); + if (strictSandbox) { + createContainerCmd.withHostConfig(sandboxHostConfig()); + } + CreateContainerResponse createContainerResponse = createContainerCmd.exec(); + String containerId = createContainerResponse.getId(); try { - dockerClient - .execStartCmd(execCreateCmdResponse.getId()) - .exec(new ExecStartResultCallback(stdout, stderr)) - .awaitCompletion(); + dockerClient.startContainerCmd(containerId).exec(); + + ExecCreateCmdResponse execCreateCmdResponse = + dockerClient + .execCreateCmd(containerId) + .withAttachStdout(true) + .withAttachStderr(true) + .withCmd("python3", "-c", codeExecutionInput.code()) + .exec(); + + boolean completed; + try (ExecStartResultCallback callback = new ExecStartResultCallback(stdout, stderr)) { + dockerClient.execStartCmd(execCreateCmdResponse.getId()).exec(callback); + if (strictSandbox) { + completed = callback.awaitCompletion(executionTimeoutSeconds, TimeUnit.SECONDS); + } else { + // No execution timeout unless the strict sandbox is enabled, matching prior behavior. + callback.awaitCompletion(); + completed = true; + } + } + + if (!completed) { + // Force-removing the container in the finally block kills the still-running execution. + return CodeExecutionResult.builder() + .stderr( + String.format( + "Code execution timed out after %d seconds.", executionTimeoutSeconds)) + .build(); + } + return CodeExecutionResult.builder() + .stdout(stdout.toString(StandardCharsets.UTF_8)) + .stderr(stderr.toString(StandardCharsets.UTF_8)) + .build(); } catch (InterruptedException e) { Thread.currentThread().interrupt(); throw new RuntimeException("Code execution was interrupted.", e); + } catch (IOException e) { + throw new UncheckedIOException(e); + } finally { + removeContainerQuietly(containerId); } - - return CodeExecutionResult.builder() - .stdout(stdout.toString(StandardCharsets.UTF_8)) - .stderr(stderr.toString(StandardCharsets.UTF_8)) - .build(); } - private void buildDockerImage() { - if (dockerPath == null) { - throw new IllegalStateException("Docker path is not set."); + /** Builds the hardened {@link HostConfig} applied to each execution container in strict mode. */ + @VisibleForTesting + HostConfig sandboxHostConfig() { + HostConfig hostConfig = + HostConfig.newHostConfig() + .withCapDrop(Capability.ALL) + .withReadonlyRootfs(true) + .withSecurityOpts(ImmutableList.of("no-new-privileges")) + .withMemory(memoryLimitBytes) + .withPidsLimit(PIDS_LIMIT) + // A read-only rootfs still needs a small writable scratch space at /tmp. + .withTmpFs(ImmutableMap.of("/tmp", "rw,size=64m")); + if (!networkEnabled) { + hostConfig.withNetworkMode("none"); } - File dockerfile = new File(dockerPath); - if (!dockerfile.exists()) { - throw new UncheckedIOException(new IOException("Invalid Docker path: " + dockerPath)); + return hostConfig; + } + + /** + * Logs a warning, at most once per executor, if the strict sandbox is disabled. Returns whether + * the warning was logged. + */ + @VisibleForTesting + boolean warnIfStrictSandboxDisabled() { + if (!strictSandbox && strictSandboxWarningLogged.compareAndSet(false, true)) { + logger.warn( + "ContainerCodeExecutor is running with the strict sandbox disabled (the current default):" + + " the execution container has network access (including the cloud metadata" + + " endpoint), a writable filesystem, and no memory/PID/time limits, so untrusted," + + " model-generated code can steal credentials, exhaust host resources, or hang the" + + " calling thread. Call setStrictSandbox(true) to run each execution in a" + + " locked-down container. This becomes the default in ADK 2.0."); + return true; } + return false; + } - logger.info("Building Docker image..."); + private void removeContainerQuietly(String containerId) { try { - dockerClient.buildImageCmd(dockerfile).withTag(image).start().awaitCompletion(); - } catch (InterruptedException e) { - Thread.currentThread().interrupt(); - throw new RuntimeException("Docker image build was interrupted.", e); + dockerClient.removeContainerCmd(containerId).withForce(true).exec(); + } catch (RuntimeException e) { + logger.warn("Failed to remove container {}", containerId, e); } - logger.info("Docker image: {} built.", image); } - private void verifyPythonInstallation() { - ExecCreateCmdResponse execCreateCmdResponse = - dockerClient.execCreateCmd(container.getId()).withCmd("which", "python3").exec(); - ByteArrayOutputStream stdout = new ByteArrayOutputStream(); - ByteArrayOutputStream stderr = new ByteArrayOutputStream(); - try (ExecStartResultCallback callback = new ExecStartResultCallback(stdout, stderr)) { - dockerClient.execStartCmd(execCreateCmdResponse.getId()).exec(callback).awaitCompletion(); - } catch (InterruptedException e) { - Thread.currentThread().interrupt(); - throw new RuntimeException("Python verification was interrupted.", e); + /** Closes the underlying Docker client, releasing its connections and threads. */ + @Override + public void close() { + try { + // Unregister the shutdown hook so a closed executor is not retained by the JVM. Throws + // IllegalStateException if the JVM is already shutting down (e.g. close() invoked from the + // hook itself), in which case there is nothing to remove. + Runtime.getRuntime().removeShutdownHook(shutdownHook); + } catch (IllegalStateException e) { + // JVM shutdown already in progress; the hook cannot (and need not) be removed. + } + try { + dockerClient.close(); } catch (IOException e) { - throw new UncheckedIOException(e); + logger.warn("Failed to close docker client", e); } } - private void initContainer() { - if (dockerClient == null) { - throw new IllegalStateException("Docker client is not initialized."); + private static DockerClient buildDockerClient(String baseUrl) { + if (baseUrl != null) { + var config = + DefaultDockerClientConfig.createDefaultConfigBuilder().withDockerHost(baseUrl).build(); + return DockerClientBuilder.getInstance(config).build(); } + return DockerClientBuilder.getInstance().build(); + } + + private void prepareImage() { if (dockerPath != null) { buildDockerImage(); } else { @@ -203,34 +369,24 @@ private void initContainer() { } logger.info("Image {} is available.", image); } - logger.info("Starting container for ContainerCodeExecutor..."); - var createContainerResponse = - dockerClient.createContainerCmd(image).withTty(true).withAttachStdin(true).exec(); - dockerClient.startContainerCmd(createContainerResponse.getId()).exec(); - - var containers = dockerClient.listContainersCmd().withShowAll(true).exec(); - this.container = - containers.stream() - .filter(c -> c.getId().equals(createContainerResponse.getId())) - .findFirst() - .orElseThrow(() -> new IllegalStateException("Failed to find the created container.")); - - logger.info("Container {} started.", container.getId()); - verifyPythonInstallation(); } - private void cleanupContainer() { - if (container == null) { - return; + private void buildDockerImage() { + if (dockerPath == null) { + throw new IllegalStateException("Docker path is not set."); } - logger.info("[Cleanup] Stopping the container..."); - dockerClient.stopContainerCmd(container.getId()).exec(); - dockerClient.removeContainerCmd(container.getId()).exec(); - logger.info("Container {} stopped and removed.", container.getId()); + File dockerfile = new File(dockerPath); + if (!dockerfile.exists()) { + throw new UncheckedIOException(new IOException("Invalid Docker path: " + dockerPath)); + } + + logger.info("Building Docker image..."); try { - dockerClient.close(); - } catch (IOException e) { - logger.warn("Failed to close docker client", e); + dockerClient.buildImageCmd(dockerfile).withTag(image).start().awaitCompletion(); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new RuntimeException("Docker image build was interrupted.", e); } + logger.info("Docker image: {} built.", image); } } diff --git a/core/src/test/java/com/google/adk/codeexecutors/ContainerCodeExecutorTest.java b/core/src/test/java/com/google/adk/codeexecutors/ContainerCodeExecutorTest.java new file mode 100644 index 000000000..a572691c3 --- /dev/null +++ b/core/src/test/java/com/google/adk/codeexecutors/ContainerCodeExecutorTest.java @@ -0,0 +1,237 @@ +/* + * Copyright 2026 Google LLC + * + * 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 com.google.adk.codeexecutors; + +import static com.google.common.truth.Truth.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import com.github.dockerjava.api.DockerClient; +import com.github.dockerjava.api.command.CreateContainerCmd; +import com.github.dockerjava.api.command.CreateContainerResponse; +import com.github.dockerjava.api.command.ExecCreateCmd; +import com.github.dockerjava.api.command.ExecCreateCmdResponse; +import com.github.dockerjava.api.command.ExecStartCmd; +import com.github.dockerjava.api.command.RemoveContainerCmd; +import com.github.dockerjava.api.command.StartContainerCmd; +import com.github.dockerjava.api.model.Capability; +import com.github.dockerjava.api.model.HostConfig; +import com.github.dockerjava.core.command.ExecStartResultCallback; +import com.google.adk.codeexecutors.CodeExecutionUtils.CodeExecutionInput; +import com.google.adk.codeexecutors.CodeExecutionUtils.CodeExecutionResult; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.junit.runners.JUnit4; +import org.mockito.ArgumentCaptor; + +/** Unit tests for {@link ContainerCodeExecutor}'s sandboxing. */ +@RunWith(JUnit4.class) +public final class ContainerCodeExecutorTest { + + private static final String IMAGE = "adk-code-executor:latest"; + private static final String CONTAINER_ID = "container-123"; + private static final String EXEC_ID = "exec-456"; + + @Test + public void sandboxHostConfig_appliesFullHardening() { + ContainerCodeExecutor executor = new ContainerCodeExecutor(mock(DockerClient.class), IMAGE); + + HostConfig hostConfig = executor.sandboxHostConfig(); + + assertThat(hostConfig.getNetworkMode()).isEqualTo("none"); + assertThat(hostConfig.getCapDrop()).asList().containsExactly(Capability.ALL); + assertThat(hostConfig.getReadonlyRootfs()).isTrue(); + assertThat(hostConfig.getSecurityOpts()).containsExactly("no-new-privileges"); + assertThat(hostConfig.getMemory()).isEqualTo(512L * 1024 * 1024); + assertThat(hostConfig.getPidsLimit()).isEqualTo(128L); + assertThat(hostConfig.getTmpFs()).containsEntry("/tmp", "rw,size=64m"); + } + + @Test + public void sandboxHostConfig_networkEnabled_doesNotForceNoneNetwork() { + ContainerCodeExecutor executor = + new ContainerCodeExecutor(mock(DockerClient.class), IMAGE).setNetworkEnabled(true); + + HostConfig hostConfig = executor.sandboxHostConfig(); + + // When networking is explicitly enabled we leave the network mode at Docker's default. + assertThat(hostConfig.getNetworkMode()).isNull(); + // The other hardening still applies. + assertThat(hostConfig.getCapDrop()).asList().containsExactly(Capability.ALL); + assertThat(hostConfig.getReadonlyRootfs()).isTrue(); + } + + @Test + public void sandboxHostConfig_customMemoryLimit_applied() { + ContainerCodeExecutor executor = + new ContainerCodeExecutor(mock(DockerClient.class), IMAGE) + .setMemoryLimitBytes(256L * 1024 * 1024); + + assertThat(executor.sandboxHostConfig().getMemory()).isEqualTo(256L * 1024 * 1024); + } + + @Test + public void executeCode_strictSandbox_execsInHardenedContainerAndForceRemovesIt() { + DockerClient client = mockDockerClient(/* driveCompletion= */ true); + ContainerCodeExecutor executor = + new ContainerCodeExecutor(client, IMAGE).setStrictSandbox(true); + + CodeExecutionResult result = + executor.executeCode( + /* invocationContext= */ null, + CodeExecutionInput.builder().code("print('hi')").build()); + + CreateContainerCmd createCmd = client.createContainerCmd(IMAGE); + + // The container is created with the hardened HostConfig... + ArgumentCaptor hostConfigCaptor = ArgumentCaptor.forClass(HostConfig.class); + verify(createCmd).withHostConfig(hostConfigCaptor.capture()); + assertThat(hostConfigCaptor.getValue().getNetworkMode()).isEqualTo("none"); + assertThat(hostConfigCaptor.getValue().getReadonlyRootfs()).isTrue(); + + // ...the code runs via docker exec (bypasses ENTRYPOINT; needs only python3)... + ArgumentCaptor cmdCaptor = ArgumentCaptor.forClass(String[].class); + verify(client.execCreateCmd(CONTAINER_ID)).withCmd(cmdCaptor.capture()); + assertThat(cmdCaptor.getValue()) + .asList() + .containsExactly("python3", "-c", "print('hi')") + .inOrder(); + + // ...and the container is force-removed afterwards. + verify(client.startContainerCmd(CONTAINER_ID)).exec(); + verify(client.removeContainerCmd(CONTAINER_ID)).withForce(true); + verify(client.removeContainerCmd(CONTAINER_ID)).exec(); + assertThat(result.stderr()).isEmpty(); + } + + @Test + public void executeCode_timeout_returnsTimeoutResultAndForceRemovesContainer() { + DockerClient client = mockDockerClient(/* driveCompletion= */ false); + ContainerCodeExecutor executor = + new ContainerCodeExecutor(client, IMAGE) + .setStrictSandbox(true) + .setExecutionTimeoutSeconds(1); + + CodeExecutionResult result = + executor.executeCode( + /* invocationContext= */ null, + CodeExecutionInput.builder().code("while True: pass").build()); + + assertThat(result.stderr()).contains("timed out"); + // The runaway container is force-removed, which kills the exec. + verify(client.removeContainerCmd(CONTAINER_ID)).withForce(true); + verify(client.removeContainerCmd(CONTAINER_ID)).exec(); + } + + @Test + public void executeCode_default_doesNotApplyHostConfig() { + DockerClient client = mockDockerClient(/* driveCompletion= */ true); + ContainerCodeExecutor executor = new ContainerCodeExecutor(client, IMAGE); + + CodeExecutionResult result = + executor.executeCode( + /* invocationContext= */ null, + CodeExecutionInput.builder().code("print('hi')").build()); + + // No hardened HostConfig is applied by default, preserving existing behavior... + CreateContainerCmd createCmd = client.createContainerCmd(IMAGE); + verify(createCmd, never()).withHostConfig(any()); + // ...but the code still runs via docker exec and the container is force-removed. + verify(client.execCreateCmd(CONTAINER_ID)).withCmd(any(String[].class)); + verify(client.removeContainerCmd(CONTAINER_ID)).withForce(true); + verify(client.removeContainerCmd(CONTAINER_ID)).exec(); + assertThat(result.stderr()).isEmpty(); + } + + @Test + public void warnIfStrictSandboxDisabled_sandboxDisabled_warnsOnlyOnce() { + ContainerCodeExecutor executor = new ContainerCodeExecutor(mock(DockerClient.class), IMAGE); + + // The dangerous default is flagged, but only once per executor so it cannot spam the logs. + assertThat(executor.warnIfStrictSandboxDisabled()).isTrue(); + assertThat(executor.warnIfStrictSandboxDisabled()).isFalse(); + } + + @Test + public void warnIfStrictSandboxDisabled_strictSandbox_doesNotWarn() { + ContainerCodeExecutor executor = + new ContainerCodeExecutor(mock(DockerClient.class), IMAGE).setStrictSandbox(true); + + assertThat(executor.warnIfStrictSandboxDisabled()).isFalse(); + } + + @Test + public void close_closesDockerClient() throws Exception { + DockerClient client = mock(DockerClient.class); + ContainerCodeExecutor executor = new ContainerCodeExecutor(client, IMAGE); + + executor.close(); + + verify(client).close(); + } + + /** + * Builds a mock {@link DockerClient} whose create/start/exec/remove chain succeeds. When {@code + * driveCompletion} is true the exec callback is completed immediately so {@code awaitCompletion} + * returns without blocking; otherwise it is left pending so the executor's timeout fires. + */ + private static DockerClient mockDockerClient(boolean driveCompletion) { + DockerClient client = mock(DockerClient.class); + + CreateContainerCmd createCmd = mock(CreateContainerCmd.class); + when(client.createContainerCmd(IMAGE)).thenReturn(createCmd); + when(createCmd.withHostConfig(any())).thenReturn(createCmd); + when(createCmd.withTty(any())).thenReturn(createCmd); + when(createCmd.withAttachStdin(any())).thenReturn(createCmd); + CreateContainerResponse createResponse = mock(CreateContainerResponse.class); + when(createResponse.getId()).thenReturn(CONTAINER_ID); + when(createCmd.exec()).thenReturn(createResponse); + + StartContainerCmd startCmd = mock(StartContainerCmd.class); + when(client.startContainerCmd(CONTAINER_ID)).thenReturn(startCmd); + + ExecCreateCmd execCreateCmd = mock(ExecCreateCmd.class); + when(client.execCreateCmd(CONTAINER_ID)).thenReturn(execCreateCmd); + when(execCreateCmd.withAttachStdout(any())).thenReturn(execCreateCmd); + when(execCreateCmd.withAttachStderr(any())).thenReturn(execCreateCmd); + when(execCreateCmd.withCmd(any(String[].class))).thenReturn(execCreateCmd); + ExecCreateCmdResponse execCreateResponse = mock(ExecCreateCmdResponse.class); + when(execCreateResponse.getId()).thenReturn(EXEC_ID); + when(execCreateCmd.exec()).thenReturn(execCreateResponse); + + ExecStartCmd execStartCmd = mock(ExecStartCmd.class); + when(client.execStartCmd(EXEC_ID)).thenReturn(execStartCmd); + when(execStartCmd.exec(any())) + .thenAnswer( + invocation -> { + ExecStartResultCallback callback = invocation.getArgument(0); + if (driveCompletion) { + callback.onComplete(); + } + return callback; + }); + + RemoveContainerCmd removeCmd = mock(RemoveContainerCmd.class); + when(client.removeContainerCmd(CONTAINER_ID)).thenReturn(removeCmd); + when(removeCmd.withForce(any())).thenReturn(removeCmd); + + return client; + } +}