Repository navigation
fix(harness): pass docker exec scripts via stdin on Windows (nested quotes survive) #3383
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -35,6 +35,7 @@ | |
| import java.nio.file.attribute.PosixFilePermissions; | ||
| import java.util.ArrayList; | ||
| import java.util.List; | ||
| import java.util.Locale; | ||
| import java.util.Map; | ||
| import java.util.concurrent.ExecutorService; | ||
| import java.util.concurrent.Executors; | ||
|
|
@@ -141,28 +142,68 @@ public void shutdown() throws Exception { | |
| } | ||
| } | ||
|
|
||
| @Override | ||
| protected ExecResult doExec(RuntimeContext runtimeContext, String command, int timeoutSeconds) | ||
| throws Exception { | ||
| String containerId = dockerState.getContainerId(); | ||
| String workspaceRoot = dockerState.getWorkspaceRoot(); | ||
| /** | ||
| * Writes the script to the process stdin and closes the stream (EOF tells non-interactive | ||
| * {@code sh} to run the buffered script). Package-private seam so the stdin transport — | ||
| * the actual #2924 fix — is testable without a Docker daemon. | ||
| */ | ||
| static void writeScriptTo(java.io.OutputStream stdin, String command) | ||
| throws java.io.IOException { | ||
| try (OutputStream out = stdin) { | ||
| out.write(command.getBytes(StandardCharsets.UTF_8)); | ||
| } | ||
| } | ||
|
|
||
| /** | ||
| * Builds the {@code docker exec} argument list for a shell command. On Windows the script | ||
| * travels via stdin ({@code -i} + plain {@code sh}, no {@code -c}): Java's {@link | ||
| * ProcessBuilder} quotes argv into a single command line for {@code CreateProcess}, and a | ||
| * native child (docker.exe) re-parses that line with MSVCRT-style rules, which strips the | ||
| * nested double quotes sandbox commands rely on (e.g. {@code "$(dirname ...)"}); a | ||
| * Linux/macOS host passes them through untouched (#2924). | ||
| * | ||
| * <p>Package-private for tests. | ||
| */ | ||
| static List<String> buildExecArgs( | ||
| String command, String workspaceRoot, String containerId, boolean windows) { | ||
| List<String> cmd = new ArrayList<>(); | ||
| cmd.add("docker"); | ||
| cmd.add("exec"); | ||
| if (windows) { | ||
| cmd.add("-i"); | ||
| } | ||
| cmd.add("-w"); | ||
| cmd.add(workspaceRoot); | ||
| cmd.add(containerId); | ||
| cmd.add("sh"); | ||
| cmd.add("-c"); | ||
| cmd.add(command); | ||
| if (windows) { | ||
| // no "-c": sh executes the whole stdin as the script | ||
| } else { | ||
| cmd.add("-c"); | ||
| cmd.add(command); | ||
| } | ||
| return cmd; | ||
| } | ||
|
|
||
| @Override | ||
| protected ExecResult doExec(RuntimeContext runtimeContext, String command, int timeoutSeconds) | ||
| throws Exception { | ||
| String containerId = dockerState.getContainerId(); | ||
| String workspaceRoot = dockerState.getWorkspaceRoot(); | ||
|
|
||
| boolean windows = | ||
| System.getProperty("os.name", "").toLowerCase(Locale.ROOT).contains("win"); | ||
| List<String> cmd = buildExecArgs(command, workspaceRoot, containerId, windows); | ||
|
|
||
| ProcessBuilder pb = new ProcessBuilder(cmd); | ||
| Process process = pb.start(); | ||
|
|
||
| // 3 threads on Windows: stdout drainer + stderr drainer + the stdin writer — the | ||
| // drainers only finish at process EOF, which never comes while sh waits for stdin, | ||
| // so a 2-thread pool would starve the write into a guaranteed deadlock (#2924 review). | ||
| ExecutorService drainer = | ||
| Executors.newFixedThreadPool( | ||
| 2, | ||
| windows ? 3 : 2, | ||
| r -> { | ||
| Thread t = | ||
| new Thread( | ||
|
|
@@ -176,6 +217,34 @@ protected ExecResult doExec(RuntimeContext runtimeContext, String command, int t | |
| drainer.submit(() -> readStream(process.getInputStream(), OUTPUT_TRUNCATE_BYTES)); | ||
| Future<String> stderrFuture = | ||
| drainer.submit(() -> readStream(process.getErrorStream(), OUTPUT_TRUNCATE_BYTES)); | ||
|
|
||
| if (windows) { | ||
| // The script travels via stdin (-i, no -c): write it, then close so sh runs the | ||
| // buffered script to completion and exits with its status (#2924). Written from | ||
| // the drainer pool AFTER the output drainers started, so early child output can | ||
| // never fill the stdout pipe and deadlock the write before waitFor() arms the | ||
| // timeout (#2924 review). | ||
| Future<?> stdinWrite = | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The Windows stdin write is submitted as a third task to the same fixed 2-thread pool ( |
||
| drainer.submit( | ||
| () -> { | ||
| try { | ||
| writeScriptTo(process.getOutputStream(), command); | ||
| return null; | ||
| } catch (IOException e) { | ||
| throw new java.util.concurrent.CompletionException(e); | ||
| } | ||
| }); | ||
| try { | ||
| stdinWrite.get(timeoutSeconds, TimeUnit.SECONDS); | ||
|
Comment on lines
+237
to
+238
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Budget nit, non-blocking: this long deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(timeoutSeconds);
// ... then, for each wait:
long remainingNanos = deadline - System.nanoTime();
if (remainingNanos <= 0) { /* timeout path */ }
stdinWrite.get(remainingNanos, TimeUnit.NANOSECONDS);Mentioning it only because |
||
| } catch (Exception stdinFailure) { | ||
|
Comment on lines
+237
to
+239
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Two consequences:
Suggest splitting the catch, e.g.: try {
stdinWrite.get(timeoutSeconds, TimeUnit.SECONDS);
} catch (TimeoutException e) {
process.destroyForcibly();
drainer.shutdownNow();
throw new SandboxException.ExecTimeoutException(command, timeoutSeconds);
} catch (InterruptedException e) {
process.destroyForcibly();
drainer.shutdownNow();
Thread.currentThread().interrupt();
throw new SandboxException(
SandboxErrorCode.EXEC_NONZERO, "Interrupted writing script to docker exec stdin", e);
} catch (ExecutionException e) {
process.destroyForcibly();
drainer.shutdownNow();
throw new SandboxException(
SandboxErrorCode.EXEC_NONZERO,
"Failed to write script to docker exec stdin",
e.getCause() != null ? e.getCause() : e);
}Also note |
||
| process.destroyForcibly(); | ||
| drainer.shutdownNow(); | ||
| throw new SandboxException( | ||
| SandboxErrorCode.EXEC_NONZERO, | ||
| "Failed to write script to docker exec stdin", | ||
| stdinFailure); | ||
| } | ||
| } | ||
| drainer.shutdown(); | ||
|
|
||
| boolean exited = process.waitFor(timeoutSeconds, TimeUnit.SECONDS); | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,85 @@ | ||
| /* | ||
| * 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.assertEquals; | ||
| import static org.junit.jupiter.api.Assertions.assertFalse; | ||
| import static org.junit.jupiter.api.Assertions.assertTrue; | ||
|
|
||
| import java.io.ByteArrayOutputStream; | ||
| import java.io.OutputStream; | ||
| import java.util.List; | ||
| import org.junit.jupiter.api.Test; | ||
|
|
||
| /** | ||
| * Regression tests for the Windows nested-quote loss in {@code doExec} (#2924): on Windows the | ||
| * script must travel via stdin ({@code -i} + plain {@code sh}), because {@link ProcessBuilder} | ||
| * funnels arguments through {@code cmd.exe}, which strips the nested double quotes sandbox | ||
|
Comment on lines
+28
to
+30
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Stale rationale, and it now contradicts the corrected Javadoc in the same PR. This comment still says Worth aligning both to the |
||
| * commands rely on. | ||
| */ | ||
| class DockerSandboxExecArgsTest { | ||
|
|
||
| private static final String CMD = | ||
| "if [ -e 'test.txt' ]; then echo 'EXISTS'; exit 1; fi; " | ||
| + "mkdir -p \"$(dirname 'test.txt')\" 2>&1"; | ||
|
|
||
| @Test | ||
| void unixPath_usesShDashCWithScriptInline() { | ||
| List<String> args = DockerSandbox.buildExecArgs(CMD, "/workspace", "container-1", false); | ||
|
|
||
| assertEquals( | ||
| List.of("docker", "exec", "-w", "/workspace", "container-1", "sh", "-c", CMD), | ||
| args); | ||
| } | ||
|
|
||
| @Test | ||
| void windowsPath_passesScriptViaStdin() { | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The tests only assert on the new pure helper buildExecArgs; nothing exercises the actual fix in doExec (writing the script to process stdin and closing it, DockerSandbox.java:188-194). Deleting that block in doExec leaves all three tests green, so the regression is pinned only structurally, not behaviorally. windowsStdin_scriptSurvivesNestedDoubleQuotes is additionally trivially true because the command never appears in the Windows args by construction (its assertion is implied by the test above it and is effectively redundant). Consider extracting the stdin write into a package-private seam that takes a Process/OutputStream and testing it (script bytes are written, stream is closed), or make doExec's command injectable so the write path can be driven in a test. |
||
| List<String> args = DockerSandbox.buildExecArgs(CMD, "/workspace", "container-1", true); | ||
|
|
||
| // -i + plain sh: the script is fed on stdin, never placed on the Windows command line. | ||
| assertEquals( | ||
| List.of("docker", "exec", "-i", "-w", "/workspace", "container-1", "sh"), args); | ||
| assertFalse( | ||
| args.contains("-c"), | ||
| "no -c on Windows: the script must not travel through cmd.exe"); | ||
| } | ||
|
|
||
| @Test | ||
| void windowsStdin_writeScriptTo_writesAndCloses() throws Exception { | ||
| // Behavioral pin of the actual #2924 fix: the seam writes the full script bytes and | ||
| // closes the stream (EOF tells non-interactive sh to run the buffered script). | ||
| ByteArrayOutputStream sink = new ByteArrayOutputStream(); | ||
| OutputStream counted = | ||
| new OutputStream() { | ||
| @Override | ||
| public void write(int b) { | ||
| sink.write(b); | ||
| } | ||
|
|
||
| @Override | ||
| public void close() { | ||
| closed = true; | ||
| } | ||
| }; | ||
|
|
||
| DockerSandbox.writeScriptTo(counted, CMD); | ||
|
|
||
| assertEquals(CMD, sink.toString("UTF-8"), "the script bytes must reach stdin verbatim"); | ||
| assertTrue(closed, "stdin must be closed so sh sees EOF"); | ||
| } | ||
|
|
||
| private volatile boolean closed; | ||
| } | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This is the line that resolves the Critical from my previous review, and it is the one thing in the PR that no test can currently catch regressing. Reverting
windows ? 3 : 2back to2reintroduces the deterministic Windows deadlock (the stdin writer queues behind two EOF-blocked drainers), and every existing test still passes:buildExecArgsonly pins argv shape,writeScriptToonly pins write-and-close on aByteArrayOutputStream, anddrainersizing is insidedoExec, which the test class never touches.build (windows-latest)is green here too, because CI does not run a Docker-backeddoExecagainst a Windows host — the executor sizing is decided by the host OS, so on the Linux CI runner this branch never even executes.I know the stub-docker orchestration test was deferred as follow-up (I can see it in your
d240e15comment) — flagging it as an accepted gap rather than a blocker. A cheap interim pin if you'd rather not carry a TODO: extract the capacity decision into the same kind of package-private seam asbuildExecArgs/writeScriptTo, e.g.static int drainerPoolSize(boolean windows) { return windows ? 3 : 2; }, and assertdrainerPoolSize(true) >= 3next to the two existing arg tests. That doesn't cover real sequencing, but it does make the exact regression that caused this round of review fail a test instead of a user's Windows session.