command) throws IOException {
+ return new ProcessBuilder(command).start();
+ }
+
+ /**
+ * Wraps the command in a compound command before streaming it to {@code sh -s}.
+ *
+ * The shell parses the complete compound command before applying the {@code /dev/null}
+ * redirection. Commands such as {@code read} therefore cannot consume the remaining script,
+ * while heredocs and explicit stdin redirections continue to work.
+ */
+ static String wrapCommandForStdin(String command) {
+ String trailingNewline = command.endsWith("\n") ? "" : "\n";
+ return "{\n:\n" + command + trailingNewline + "} writer) {
+ try {
+ writer.get();
+ } catch (InterruptedException interrupted) {
+ Thread.currentThread().interrupt();
+ } catch (Exception ignored) {
+ // Preserve the command's non-zero exit as the primary failure.
+ }
+ }
+
@Override
protected InputStream doPersistWorkspace() throws Exception {
String containerId = dockerState.getContainerId();
diff --git a/agentscope-harness/src/test/java/io/agentscope/harness/agent/sandbox/impl/docker/DockerSandboxExecTest.java b/agentscope-harness/src/test/java/io/agentscope/harness/agent/sandbox/impl/docker/DockerSandboxExecTest.java
new file mode 100644
index 0000000000..ca0c40108e
--- /dev/null
+++ b/agentscope-harness/src/test/java/io/agentscope/harness/agent/sandbox/impl/docker/DockerSandboxExecTest.java
@@ -0,0 +1,226 @@
+/*
+ * 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.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import io.agentscope.harness.agent.sandbox.ExecResult;
+import io.agentscope.harness.agent.sandbox.SandboxException;
+import io.agentscope.harness.agent.sandbox.WorkspaceSpec;
+import java.io.ByteArrayInputStream;
+import java.io.ByteArrayOutputStream;
+import java.io.InputStream;
+import java.io.OutputStream;
+import java.nio.charset.StandardCharsets;
+import java.util.List;
+import java.util.concurrent.TimeUnit;
+import org.junit.jupiter.api.Test;
+
+class DockerSandboxExecTest {
+
+ private static final int OUTPUT_LIMIT = 512 * 1024;
+
+ @Test
+ void streamsNestedQuotesAndUnicodeThroughStdinInsteadOfWindowsArgv() throws Exception {
+ String command =
+ "mkdir -p \"$(dirname 'test file.txt')\"; "
+ + "printf '%s\\n' \"nested \\\"quotes\\\" δΈη π\"";
+ FakeProcess process = FakeProcess.success("done\n", "");
+ TestDockerSandbox sandbox = new TestDockerSandbox(state(), process);
+
+ ExecResult result = sandbox.execute(command, 30);
+
+ assertEquals(
+ List.of("docker", "exec", "-i", "-w", "/workspace", "container-123", "sh", "-s"),
+ sandbox.startedCommand);
+ assertFalse(sandbox.startedCommand.contains(command));
+ assertArrayEquals(
+ DockerSandbox.wrapCommandForStdin(command).getBytes(StandardCharsets.UTF_8),
+ process.stdin.toByteArray());
+ assertTrue(process.stdin.closed);
+ assertEquals("done\n", result.stdout());
+ assertEquals("", result.stderr());
+ assertFalse(result.truncated());
+ }
+
+ @Test
+ void wrapsMultilineAndHeredocWithoutChangingTheirBytes() throws Exception {
+ String command =
+ "python3 - <<'PY'\n" + "print(\"nested 'quotes'\")\n" + "print('ε€θ‘')\n" + "PY\n";
+ FakeProcess process = FakeProcess.success("", "");
+ TestDockerSandbox sandbox = new TestDockerSandbox(state(), process);
+
+ sandbox.execute(command, 30);
+
+ String expected = "{\n:\n" + command + "} sandbox.execute("echo broken >&2; exit 7", 30));
+
+ assertEquals(7, error.getExitCode());
+ assertEquals("partial", error.getStdout());
+ assertEquals("failed", error.getStderr());
+ assertTrue(process.stdin.closed);
+ }
+
+ @Test
+ void destroysProcessAndClosesStdinOnTimeout() {
+ FakeProcess process = new FakeProcess(false, 0, "", "");
+ TestDockerSandbox sandbox = new TestDockerSandbox(state(), process);
+
+ assertThrows(
+ SandboxException.ExecTimeoutException.class, () -> sandbox.execute("sleep 60", 1));
+
+ assertTrue(process.destroyed);
+ assertTrue(process.stdin.closed);
+ }
+
+ @Test
+ void retainsOutputTruncationBehavior() throws Exception {
+ byte[] oversized = new byte[OUTPUT_LIMIT + 32];
+ java.util.Arrays.fill(oversized, (byte) 'x');
+ FakeProcess process = new FakeProcess(true, 0, oversized, new byte[0]);
+ TestDockerSandbox sandbox = new TestDockerSandbox(state(), process);
+
+ ExecResult result = sandbox.execute("printf output", 30);
+
+ assertEquals(OUTPUT_LIMIT, result.stdout().length());
+ assertTrue(result.truncated());
+ }
+
+ private static DockerSandboxState state() {
+ DockerSandboxState state = new DockerSandboxState();
+ state.setContainerId("container-123");
+ state.setWorkspaceRoot("/workspace");
+ state.setWorkspaceSpec(new WorkspaceSpec());
+ return state;
+ }
+
+ private static final class TestDockerSandbox extends DockerSandbox {
+
+ private final Process process;
+ private List startedCommand;
+
+ private TestDockerSandbox(DockerSandboxState state, Process process) {
+ super(state);
+ this.process = process;
+ }
+
+ @Override
+ Process startExecProcess(List command) {
+ this.startedCommand = List.copyOf(command);
+ return process;
+ }
+
+ private ExecResult execute(String command, int timeoutSeconds) throws Exception {
+ return doExec(null, command, timeoutSeconds);
+ }
+ }
+
+ private static final class FakeProcess extends Process {
+
+ private final boolean exits;
+ private final int exitCode;
+ private final InputStream stdout;
+ private final InputStream stderr;
+ private final TrackingOutputStream stdin = new TrackingOutputStream();
+ private volatile boolean destroyed;
+
+ private FakeProcess(boolean exits, int exitCode, String stdout, String stderr) {
+ this(
+ exits,
+ exitCode,
+ stdout.getBytes(StandardCharsets.UTF_8),
+ stderr.getBytes(StandardCharsets.UTF_8));
+ }
+
+ private FakeProcess(boolean exits, int exitCode, byte[] stdout, byte[] stderr) {
+ this.exits = exits;
+ this.exitCode = exitCode;
+ this.stdout = new ByteArrayInputStream(stdout);
+ this.stderr = new ByteArrayInputStream(stderr);
+ }
+
+ private static FakeProcess success(String stdout, String stderr) {
+ return new FakeProcess(true, 0, stdout, stderr);
+ }
+
+ @Override
+ public OutputStream getOutputStream() {
+ return stdin;
+ }
+
+ @Override
+ public InputStream getInputStream() {
+ return stdout;
+ }
+
+ @Override
+ public InputStream getErrorStream() {
+ return stderr;
+ }
+
+ @Override
+ public int waitFor() {
+ return exitCode;
+ }
+
+ @Override
+ public boolean waitFor(long timeout, TimeUnit unit) {
+ return exits;
+ }
+
+ @Override
+ public int exitValue() {
+ return exitCode;
+ }
+
+ @Override
+ public void destroy() {
+ destroyed = true;
+ }
+
+ @Override
+ public Process destroyForcibly() {
+ destroyed = true;
+ return this;
+ }
+ }
+
+ private static final class TrackingOutputStream extends ByteArrayOutputStream {
+
+ private volatile boolean closed;
+
+ @Override
+ public void close() {
+ closed = true;
+ }
+ }
+}
From 99040c8b2548366baa0aba93dbe1d3a13deb754b Mon Sep 17 00:00:00 2001
From: guslegend <1670547022@qq.com>
Date: Tue, 25 Aug 2026 15:50:20 +0800
Subject: [PATCH 2/2] fix(test): close dynamic hook test agents
---
.../HarnessAgentDynamicHookBuilderTest.java | 201 ++++++++++--------
1 file changed, 115 insertions(+), 86 deletions(-)
diff --git a/agentscope-harness/src/test/java/io/agentscope/harness/agent/HarnessAgentDynamicHookBuilderTest.java b/agentscope-harness/src/test/java/io/agentscope/harness/agent/HarnessAgentDynamicHookBuilderTest.java
index 12f6183a60..6254219827 100644
--- a/agentscope-harness/src/test/java/io/agentscope/harness/agent/HarnessAgentDynamicHookBuilderTest.java
+++ b/agentscope-harness/src/test/java/io/agentscope/harness/agent/HarnessAgentDynamicHookBuilderTest.java
@@ -44,11 +44,13 @@
import io.agentscope.harness.agent.middleware.SubagentsMiddleware;
import java.nio.file.Files;
import java.nio.file.Path;
+import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.Map;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.stream.Collectors;
+import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import org.mockito.ArgumentCaptor;
@@ -75,17 +77,24 @@
class HarnessAgentDynamicHookBuilderTest {
@TempDir Path workspace;
+ private final List agents = new ArrayList<>();
+
+ @AfterEach
+ void closeAgents() {
+ agents.forEach(HarnessAgent::close);
+ }
@Test
void defaultBuild_registersDynamicSkillAndSubagentMiddlewares() throws Exception {
Files.createDirectories(workspace);
HarnessAgent agent =
- HarnessAgent.builder()
- .name("t")
- .model(stubModel("ok"))
- .workspace(workspace)
- .abstractFilesystem(new LocalFilesystem(workspace))
- .build();
+ track(
+ HarnessAgent.builder()
+ .name("t")
+ .model(stubModel("ok"))
+ .workspace(workspace)
+ .abstractFilesystem(new LocalFilesystem(workspace))
+ .build());
List mws = agent.getDelegate().getMiddlewares();
assertTrue(
@@ -108,13 +117,14 @@ void customSkillRepository_composesWithDynamicMiddleware() throws Exception {
AgentSkillRepository emptyRepo = new EmptySkillRepository();
HarnessAgent agent =
- HarnessAgent.builder()
- .name("t")
- .model(stubModel("ok"))
- .workspace(workspace)
- .abstractFilesystem(new LocalFilesystem(workspace))
- .skillRepository(emptyRepo)
- .build();
+ track(
+ HarnessAgent.builder()
+ .name("t")
+ .model(stubModel("ok"))
+ .workspace(workspace)
+ .abstractFilesystem(new LocalFilesystem(workspace))
+ .skillRepository(emptyRepo)
+ .build());
List mws = agent.getDelegate().getMiddlewares();
assertTrue(
@@ -126,13 +136,14 @@ void customSkillRepository_composesWithDynamicMiddleware() throws Exception {
void disableDynamicSkills_skipsDynamicSkillMiddleware() throws Exception {
Files.createDirectories(workspace);
HarnessAgent agent =
- HarnessAgent.builder()
- .name("t")
- .model(stubModel("ok"))
- .workspace(workspace)
- .abstractFilesystem(new LocalFilesystem(workspace))
- .disableDynamicSkills()
- .build();
+ track(
+ HarnessAgent.builder()
+ .name("t")
+ .model(stubModel("ok"))
+ .workspace(workspace)
+ .abstractFilesystem(new LocalFilesystem(workspace))
+ .disableDynamicSkills()
+ .build());
List mws = agent.getDelegate().getMiddlewares();
assertFalse(
@@ -154,14 +165,15 @@ void disableDynamicSkills_freezesRepositoriesIntoStaticMiddleware() throws Excep
null)));
HarnessAgent agent =
- HarnessAgent.builder()
- .name("t")
- .model(model)
- .workspace(workspace)
- .abstractFilesystem(new LocalFilesystem(workspace))
- .skillRepository(repository)
- .disableDynamicSkills()
- .build();
+ track(
+ HarnessAgent.builder()
+ .name("t")
+ .model(model)
+ .workspace(workspace)
+ .abstractFilesystem(new LocalFilesystem(workspace))
+ .skillRepository(repository)
+ .disableDynamicSkills()
+ .build());
assertEquals(
1,
@@ -223,21 +235,27 @@ void disableDynamicSkills_appliesBuilderAndVisibilityFiltersToPromptAndLoader()
skill("gamma", "gamma description")));
HarnessAgent agent =
- HarnessAgent.builder()
- .name("t")
- .model(model)
- .workspace(workspace)
- .abstractFilesystem(new LocalFilesystem(workspace))
- .skillRepository(repository)
- .enableSkills("alpha", "beta")
- .enableSkillPromotionGate(
- null,
- (skills, ctx) ->
- skills.stream()
- .filter(skill -> !"alpha".equals(skill.getName()))
- .toList())
- .disableDynamicSkills()
- .build();
+ track(
+ HarnessAgent.builder()
+ .name("t")
+ .model(model)
+ .workspace(workspace)
+ .abstractFilesystem(new LocalFilesystem(workspace))
+ .skillRepository(repository)
+ .enableSkills("alpha", "beta")
+ .enableSkillPromotionGate(
+ null,
+ (skills, ctx) ->
+ skills.stream()
+ .filter(
+ skill ->
+ !"alpha"
+ .equals(
+ skill
+ .getName()))
+ .toList())
+ .disableDynamicSkills()
+ .build());
RuntimeContext ctx = RuntimeContext.builder().sessionId("filtered-static").build();
agent.call("hello", ctx).block();
@@ -266,15 +284,16 @@ void disableDynamicSkills_skillsEnabledFalseLeavesCatalogEmpty() throws Exceptio
new CountingSkillRepository(List.of(skill("disabled", "must stay hidden")));
HarnessAgent agent =
- HarnessAgent.builder()
- .name("t")
- .model(model)
- .workspace(workspace)
- .abstractFilesystem(new LocalFilesystem(workspace))
- .skillRepository(repository)
- .skillsEnabled(false)
- .disableDynamicSkills()
- .build();
+ track(
+ HarnessAgent.builder()
+ .name("t")
+ .model(model)
+ .workspace(workspace)
+ .abstractFilesystem(new LocalFilesystem(workspace))
+ .skillRepository(repository)
+ .skillsEnabled(false)
+ .disableDynamicSkills()
+ .build());
RuntimeContext ctx = RuntimeContext.builder().sessionId("no-static-skills").build();
agent.call("hello", ctx).block();
@@ -294,13 +313,14 @@ void disableDynamicSkills_keepsWorkspaceLazyResourcesLoadable() throws Exception
Files.writeString(skillDir.resolve("references/guide.md"), "lazy reference body");
HarnessAgent agent =
- HarnessAgent.builder()
- .name("t")
- .model(stubModel("ok"))
- .workspace(workspace)
- .abstractFilesystem(new LocalFilesystem(workspace))
- .disableDynamicSkills()
- .build();
+ track(
+ HarnessAgent.builder()
+ .name("t")
+ .model(stubModel("ok"))
+ .workspace(workspace)
+ .abstractFilesystem(new LocalFilesystem(workspace))
+ .disableDynamicSkills()
+ .build());
RuntimeContext ctx = RuntimeContext.builder().sessionId("lazy-static").build();
agent.call("hello", ctx).block();
@@ -332,13 +352,14 @@ void getSkillRepositories_exposesComposedListInOrder() throws Exception {
AgentSkillRepository custom = new EmptySkillRepository();
HarnessAgent agent =
- HarnessAgent.builder()
- .name("t")
- .model(stubModel("ok"))
- .workspace(workspace)
- .abstractFilesystem(new LocalFilesystem(workspace))
- .skillRepository(custom)
- .build();
+ track(
+ HarnessAgent.builder()
+ .name("t")
+ .model(stubModel("ok"))
+ .workspace(workspace)
+ .abstractFilesystem(new LocalFilesystem(workspace))
+ .skillRepository(custom)
+ .build());
List repos = agent.getSkillRepositories();
assertNotNull(repos, "getSkillRepositories() must never return null");
@@ -357,13 +378,14 @@ void getSkillRepositories_exposesComposedListInOrder() throws Exception {
void getSkillRepositories_isEmptyWhenNothingComposed() throws Exception {
Files.createDirectories(workspace);
HarnessAgent agent =
- HarnessAgent.builder()
- .name("t")
- .model(stubModel("ok"))
- .workspace(workspace)
- .abstractFilesystem(new LocalFilesystem(workspace))
- .disableDynamicSkills()
- .build();
+ track(
+ HarnessAgent.builder()
+ .name("t")
+ .model(stubModel("ok"))
+ .workspace(workspace)
+ .abstractFilesystem(new LocalFilesystem(workspace))
+ .disableDynamicSkills()
+ .build());
assertNotNull(agent.getSkillRepositories());
}
@@ -372,12 +394,13 @@ void getSkillRepositories_isEmptyWhenNothingComposed() throws Exception {
void getSkillRepositories_returnsImmutableList() throws Exception {
Files.createDirectories(workspace);
HarnessAgent agent =
- HarnessAgent.builder()
- .name("t")
- .model(stubModel("ok"))
- .workspace(workspace)
- .abstractFilesystem(new LocalFilesystem(workspace))
- .build();
+ track(
+ HarnessAgent.builder()
+ .name("t")
+ .model(stubModel("ok"))
+ .workspace(workspace)
+ .abstractFilesystem(new LocalFilesystem(workspace))
+ .build());
List first = agent.getSkillRepositories();
List second = agent.getSkillRepositories();
@@ -395,13 +418,14 @@ void getSkillRepositories_returnsImmutableList() throws Exception {
void disableDynamicSubagents_fallsBackToStaticSubagentsMiddleware() throws Exception {
Files.createDirectories(workspace);
HarnessAgent agent =
- HarnessAgent.builder()
- .name("t")
- .model(stubModel("ok"))
- .workspace(workspace)
- .abstractFilesystem(new LocalFilesystem(workspace))
- .disableDynamicSubagents()
- .build();
+ track(
+ HarnessAgent.builder()
+ .name("t")
+ .model(stubModel("ok"))
+ .workspace(workspace)
+ .abstractFilesystem(new LocalFilesystem(workspace))
+ .disableDynamicSubagents()
+ .build());
List mws = agent.getDelegate().getMiddlewares();
assertFalse(
@@ -424,6 +448,11 @@ void disableDynamicSubagents_fallsBackToStaticSubagentsMiddleware() throws Excep
.orElseThrow();
}
+ private HarnessAgent track(HarnessAgent agent) {
+ agents.add(agent);
+ return agent;
+ }
+
private static AgentSkill skill(String name, String description) {
return new AgentSkill(name, description, "# " + name, null);
}