diff --git a/agentscope-harness/src/main/java/io/agentscope/harness/agent/filesystem/sandbox/BaseSandboxFilesystem.java b/agentscope-harness/src/main/java/io/agentscope/harness/agent/filesystem/sandbox/BaseSandboxFilesystem.java
index 9193cac80..a4634788d 100644
--- a/agentscope-harness/src/main/java/io/agentscope/harness/agent/filesystem/sandbox/BaseSandboxFilesystem.java
+++ b/agentscope-harness/src/main/java/io/agentscope/harness/agent/filesystem/sandbox/BaseSandboxFilesystem.java
@@ -30,8 +30,8 @@
import io.agentscope.harness.agent.filesystem.model.ReadResult;
import io.agentscope.harness.agent.filesystem.model.WriteResult;
import io.agentscope.harness.agent.filesystem.util.FilesystemUtils;
+import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
-import java.util.Base64;
import java.util.List;
import java.util.Map;
@@ -41,8 +41,8 @@
*
This class provides default implementations for all {@link AbstractFilesystem} methods by
* delegating
* to shell commands via {@link #execute}. File listing, grep, and glob use standard Unix
- * commands. Read uses server-side commands for paginated access. Write delegates content
- * transfer to {@link #uploadFiles}. Edit uses server-side commands for string replacement.
+ * commands. Read uses server-side commands for paginated access. Write and edit delegate content
+ * transfer to {@link #uploadFiles} and {@link #downloadFiles}.
*
*
Subclasses must implement:
*
@@ -163,13 +163,14 @@ public ReadResult read(RuntimeContext runtimeContext, String filePath, int offse
@Override
public WriteResult write(RuntimeContext runtimeContext, String filePath, String content) {
String escapedPath = FilesystemUtils.shellQuote(filePath);
+ String escapedParent = FilesystemUtils.shellQuote(parentDirectory(filePath));
String checkCmd =
"if [ -e "
+ escapedPath
+ " ]; then echo 'EXISTS'; exit 1; fi; "
- + "mkdir -p \"$(dirname "
- + escapedPath
- + ")\" 2>&1";
+ + "mkdir -p "
+ + escapedParent
+ + " 2>&1";
ExecuteResponse checkResult = execute(runtimeContext, checkCmd, null);
if (checkResult.exitCode() != null && checkResult.exitCode() != 0) {
@@ -180,7 +181,12 @@ public WriteResult write(RuntimeContext runtimeContext, String filePath, String
+ " because it already exists. Read and then make an"
+ " edit, or write to a new path.");
}
- return WriteResult.fail("Failed to write file '" + filePath + "'");
+ String detail = checkResult.output() != null ? checkResult.output().strip() : "";
+ return WriteResult.fail(
+ "Failed to write file '"
+ + filePath
+ + "'"
+ + (detail.isEmpty() ? "" : ": " + detail));
}
List responses =
@@ -207,84 +213,43 @@ public EditResult edit(
String oldString,
String newString,
boolean replaceAll) {
- String payload =
- "{\"path\":\""
- + jsonEscape(filePath)
- + "\","
- + "\"old\":\""
- + jsonEscape(oldString)
- + "\","
- + "\"new\":\""
- + jsonEscape(newString)
- + "\","
- + "\"replace_all\":"
- + replaceAll
- + "}";
- String payloadB64 =
- Base64.getEncoder()
- .encodeToString(payload.getBytes(java.nio.charset.StandardCharsets.UTF_8));
-
- String cmd =
- "python3 -c \"import sys, os, base64, json\\n"
- + "payload ="
- + " json.loads(base64.b64decode(sys.stdin.read().strip()).decode('utf-8'))\\n"
- + "path, old, new = payload['path'], payload['old'], payload['new']\\n"
- + "replace_all = payload.get('replace_all', False)\\n"
- + "if not os.path.isfile(path):\\n"
- + " print(json.dumps({'error': 'file_not_found'}))\\n"
- + " sys.exit(0)\\n"
- + "with open(path, 'rb') as f: text = f.read().decode('utf-8')\\n"
- + "count = text.count(old)\\n"
- + "if count == 0:\\n"
- + " print(json.dumps({'error': 'string_not_found'}))\\n"
- + " sys.exit(0)\\n"
- + "if count > 1 and not replace_all:\\n"
- + " print(json.dumps({'error': 'multiple_occurrences', 'count': count}))\\n"
- + " sys.exit(0)\\n"
- + "result = text.replace(old, new) if replace_all else text.replace(old, new,"
- + " 1)\\n"
- + "with open(path, 'wb') as f: f.write(result.encode('utf-8'))\\n"
- + "print(json.dumps({'count': count}))\\n"
- + "\" 2>&1 <<'__EDIT_EOF__'\n"
- + payloadB64
- + "\n__EDIT_EOF__\n";
-
- ExecuteResponse result = execute(runtimeContext, cmd, null);
- String output = result.output() != null ? result.output().strip() : "";
+ List downloads = downloadFiles(runtimeContext, List.of(filePath));
+ if (downloads.isEmpty()) {
+ return EditResult.fail(
+ "Error editing file '" + filePath + "': download returned no response");
+ }
- if (output.contains("\"error\"")) {
- if (output.contains("file_not_found")) {
+ FileDownloadResponse download = downloads.get(0);
+ if (!download.isSuccess() || download.content() == null) {
+ if ("file_not_found".equals(download.error())) {
return EditResult.fail("Error: File '" + filePath + "' not found");
}
- if (output.contains("string_not_found")) {
- return EditResult.fail("Error: String not found in file: '" + oldString + "'");
- }
- if (output.contains("multiple_occurrences")) {
- return EditResult.fail(
- "Error: String '"
- + oldString
- + "' appears multiple times. Use replaceAll=true to replace all"
- + " occurrences.");
- }
- return EditResult.fail("Error editing file '" + filePath + "': " + output);
+ return EditResult.fail("Error editing file '" + filePath + "': " + download.error());
}
- if (output.contains("\"count\"")) {
- try {
- int countIdx = output.indexOf("\"count\":") + 8;
- int endIdx = output.indexOf('}', countIdx);
- int count = Integer.parseInt(output.substring(countIdx, endIdx).trim());
- return EditResult.ok(filePath, count);
- } catch (NumberFormatException e) {
- return EditResult.ok(filePath, 1);
- }
+ String content =
+ normalizeLineEndings(new String(download.content(), StandardCharsets.UTF_8));
+ String normalizedOld = normalizeLineEndings(oldString);
+ String normalizedNew = normalizeLineEndings(newString);
+ Object[] replacement =
+ FilesystemUtils.performStringReplacement(
+ content, normalizedOld, normalizedNew, replaceAll);
+ if (replacement.length == 1) {
+ return EditResult.fail((String) replacement[0]);
}
- return EditResult.fail(
- "Error editing file '"
- + filePath
- + "': unexpected server response: "
- + output.substring(0, Math.min(200, output.length())));
+ String updated = (String) replacement[0];
+ int occurrences = (int) replacement[1];
+ List uploads =
+ uploadFiles(
+ runtimeContext,
+ List.of(Map.entry(filePath, updated.getBytes(StandardCharsets.UTF_8))));
+ if (uploads.isEmpty() || !uploads.get(0).isSuccess()) {
+ String error =
+ uploads.isEmpty() ? "upload returned no response" : uploads.get(0).error();
+ return EditResult.fail("Error editing file '" + filePath + "': " + error);
+ }
+ return EditResult.ok(filePath, occurrences);
}
@Override
@@ -439,14 +404,15 @@ private static long parseEpochSeconds(String s) {
return epochSec * 1000;
}
- private static String jsonEscape(String s) {
- if (s == null) {
- return "";
+ protected static String parentDirectory(String path) {
+ int slash = path.lastIndexOf('/');
+ if (slash < 0) {
+ return ".";
}
- return s.replace("\\", "\\\\")
- .replace("\"", "\\\"")
- .replace("\n", "\\n")
- .replace("\r", "\\r")
- .replace("\t", "\\t");
+ return slash == 0 ? "/" : path.substring(0, slash);
+ }
+
+ private static String normalizeLineEndings(String value) {
+ return value.replace("\r\n", "\n").replace("\r", "\n");
}
}
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 b2b96393e..d2be95948 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
@@ -113,10 +113,11 @@ public List uploadFiles(
try {
String base64Content = Base64.getEncoder().encodeToString(content);
String escapedPath = shellSingleQuote(path);
+ String escapedParent = shellSingleQuote(parentDirectory(path));
String cmd =
- "mkdir -p $(dirname "
- + escapedPath
- + ") && "
+ "mkdir -p "
+ + escapedParent
+ + " && "
+ "printf '%s' '"
+ base64Content
+ "' | base64 -d > "
diff --git a/agentscope-harness/src/test/java/io/agentscope/harness/agent/filesystem/sandbox/BaseSandboxFilesystemTest.java b/agentscope-harness/src/test/java/io/agentscope/harness/agent/filesystem/sandbox/BaseSandboxFilesystemTest.java
index d206fe70f..ed0ec85c4 100644
--- a/agentscope-harness/src/test/java/io/agentscope/harness/agent/filesystem/sandbox/BaseSandboxFilesystemTest.java
+++ b/agentscope-harness/src/test/java/io/agentscope/harness/agent/filesystem/sandbox/BaseSandboxFilesystemTest.java
@@ -20,16 +20,19 @@
import static org.junit.jupiter.api.Assertions.assertTrue;
import io.agentscope.core.agent.RuntimeContext;
+import io.agentscope.harness.agent.filesystem.model.EditResult;
import io.agentscope.harness.agent.filesystem.model.ExecuteResponse;
import io.agentscope.harness.agent.filesystem.model.FileDownloadResponse;
import io.agentscope.harness.agent.filesystem.model.FileInfo;
import io.agentscope.harness.agent.filesystem.model.FileUploadResponse;
import io.agentscope.harness.agent.filesystem.model.GlobResult;
import io.agentscope.harness.agent.filesystem.model.LsResult;
+import io.agentscope.harness.agent.filesystem.model.WriteResult;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
+import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;
@@ -130,6 +133,227 @@ void ls_reportsDirModifiedAt() {
assertTrue(dir.isDirectory());
assertFalse(dir.modifiedAt().isEmpty(), "dir modifiedAt should be populated");
}
+
+ @Test
+ void write_usesPrecomputedParentDirectory() {
+ InMemorySandboxFilesystem filesystem = new InMemorySandboxFilesystem();
+
+ WriteResult result = filesystem.write(RT, "outputs/session-1/report.txt", "content");
+
+ assertTrue(result.isSuccess());
+ assertFalse(filesystem.lastCommand.contains("dirname"));
+ assertFalse(filesystem.lastCommand.contains("$("));
+ assertTrue(filesystem.lastCommand.contains("mkdir -p 'outputs/session-1'"));
+ }
+
+ @Test
+ void write_usesDotParentForFileInCurrentDirectory() {
+ InMemorySandboxFilesystem filesystem = new InMemorySandboxFilesystem();
+
+ WriteResult result = filesystem.write(RT, "report.txt", "content");
+
+ assertTrue(result.isSuccess());
+ assertTrue(filesystem.lastCommand.contains("mkdir -p '.'"));
+ }
+
+ @Test
+ void write_usesRootParentForFileDirectlyUnderRoot() {
+ InMemorySandboxFilesystem filesystem = new InMemorySandboxFilesystem();
+
+ WriteResult result = filesystem.write(RT, "/report.txt", "content");
+
+ assertTrue(result.isSuccess());
+ assertTrue(filesystem.lastCommand.contains("mkdir -p '/'"));
+ }
+
+ @Test
+ void write_existingFileReturnsActionableError() {
+ InMemorySandboxFilesystem filesystem = new InMemorySandboxFilesystem();
+ filesystem.executeResponse = new ExecuteResponse("EXISTS", 1, false);
+
+ WriteResult result = filesystem.write(RT, "report.txt", "content");
+
+ assertFalse(result.isSuccess());
+ assertTrue(result.error().contains("already exists"));
+ }
+
+ @Test
+ void write_directoryCreationFailureIncludesShellError() {
+ InMemorySandboxFilesystem filesystem = new InMemorySandboxFilesystem();
+ filesystem.executeResponse =
+ new ExecuteResponse(
+ "Syntax error: end of file unexpected (expecting \")\")", 2, false);
+
+ WriteResult result = filesystem.write(RT, "outputs/session-1/report.txt", "content");
+
+ assertFalse(result.isSuccess());
+ assertTrue(result.error().contains("Syntax error"));
+ }
+
+ @Test
+ void write_directoryCreationFailureWithoutOutputUsesBaseError() {
+ InMemorySandboxFilesystem filesystem = new InMemorySandboxFilesystem();
+ filesystem.executeResponse = new ExecuteResponse(null, 2, false);
+
+ WriteResult result = filesystem.write(RT, "report.txt", "content");
+
+ assertFalse(result.isSuccess());
+ assertEquals("Failed to write file 'report.txt'", result.error());
+ }
+
+ @Test
+ void write_emptyUploadResponseReturnsTransferError() {
+ InMemorySandboxFilesystem filesystem = new InMemorySandboxFilesystem();
+ filesystem.uploadNoResponse = true;
+
+ WriteResult result = filesystem.write(RT, "report.txt", "content");
+
+ assertFalse(result.isSuccess());
+ assertEquals(
+ "Failed to write file 'report.txt': upload returned no response",
+ result.error());
+ }
+
+ @Test
+ void write_uploadFailureReturnsTransferError() {
+ InMemorySandboxFilesystem filesystem = new InMemorySandboxFilesystem();
+ filesystem.uploadError = "permission denied";
+
+ WriteResult result = filesystem.write(RT, "report.txt", "content");
+
+ assertFalse(result.isSuccess());
+ assertEquals("Failed to write file 'report.txt': permission denied", result.error());
+ }
+
+ @Test
+ void edit_usesFileTransferWithoutPython() {
+ InMemorySandboxFilesystem filesystem = new InMemorySandboxFilesystem();
+ filesystem.files.put("notes.txt", "hello old value".getBytes(StandardCharsets.UTF_8));
+ filesystem.executeResponse =
+ new ExecuteResponse("sh: 1: python3: not found", 127, false);
+
+ EditResult result = filesystem.edit(RT, "notes.txt", "old value", "new value", false);
+
+ assertTrue(result.isSuccess());
+ assertEquals(1, result.occurrences());
+ assertEquals(
+ "hello new value",
+ new String(filesystem.files.get("notes.txt"), StandardCharsets.UTF_8));
+ assertFalse(filesystem.executeCalled);
+ }
+
+ @Test
+ void edit_missingFileReturnsNotFound() {
+ InMemorySandboxFilesystem filesystem = new InMemorySandboxFilesystem();
+
+ EditResult result = filesystem.edit(RT, "missing.txt", "old", "new", false);
+
+ assertFalse(result.isSuccess());
+ assertEquals("Error: File 'missing.txt' not found", result.error());
+ }
+
+ @Test
+ void edit_emptyDownloadResponseReturnsTransferError() {
+ InMemorySandboxFilesystem filesystem = new InMemorySandboxFilesystem();
+ filesystem.downloadNoResponse = true;
+
+ EditResult result = filesystem.edit(RT, "notes.txt", "old", "new", false);
+
+ assertFalse(result.isSuccess());
+ assertEquals(
+ "Error editing file 'notes.txt': download returned no response",
+ result.error());
+ }
+
+ @Test
+ void edit_downloadFailureReturnsTransferError() {
+ InMemorySandboxFilesystem filesystem = new InMemorySandboxFilesystem();
+ filesystem.downloadError = "permission denied";
+
+ EditResult result = filesystem.edit(RT, "notes.txt", "old", "new", false);
+
+ assertFalse(result.isSuccess());
+ assertEquals("Error editing file 'notes.txt': permission denied", result.error());
+ }
+
+ @Test
+ void edit_nullDownloadedContentReturnsTransferError() {
+ InMemorySandboxFilesystem filesystem = new InMemorySandboxFilesystem();
+ filesystem.downloadNullContent = true;
+
+ EditResult result = filesystem.edit(RT, "notes.txt", "old", "new", false);
+
+ assertFalse(result.isSuccess());
+ assertEquals("Error editing file 'notes.txt': null", result.error());
+ }
+
+ @Test
+ void edit_replaceAllPreservesReplacementSemantics() {
+ InMemorySandboxFilesystem filesystem = new InMemorySandboxFilesystem();
+ filesystem.files.put("notes.txt", "old and old".getBytes(StandardCharsets.UTF_8));
+
+ EditResult result = filesystem.edit(RT, "notes.txt", "old", "new", true);
+
+ assertTrue(result.isSuccess());
+ assertEquals(2, result.occurrences());
+ assertEquals(
+ "new and new",
+ new String(filesystem.files.get("notes.txt"), StandardCharsets.UTF_8));
+ }
+
+ @Test
+ void edit_normalizesLineEndingsForMatchingAndReplacement() {
+ InMemorySandboxFilesystem filesystem = new InMemorySandboxFilesystem();
+ filesystem.files.put(
+ "notes.txt", "first\r\nold\rline\r\n".getBytes(StandardCharsets.UTF_8));
+
+ EditResult result =
+ filesystem.edit(RT, "notes.txt", "old\r\nline", "new\rvalue", false);
+
+ assertTrue(result.isSuccess());
+ assertEquals(
+ "first\nnew\nvalue\n",
+ new String(filesystem.files.get("notes.txt"), StandardCharsets.UTF_8));
+ }
+
+ @Test
+ void edit_multipleOccurrencesWithoutReplaceAllFails() {
+ InMemorySandboxFilesystem filesystem = new InMemorySandboxFilesystem();
+ filesystem.files.put("notes.txt", "old and old".getBytes(StandardCharsets.UTF_8));
+
+ EditResult result = filesystem.edit(RT, "notes.txt", "old", "new", false);
+
+ assertFalse(result.isSuccess());
+ assertTrue(result.error().contains("appears 2 times"));
+ assertEquals(
+ "old and old",
+ new String(filesystem.files.get("notes.txt"), StandardCharsets.UTF_8));
+ }
+
+ @Test
+ void edit_uploadFailureReturnsTransferError() {
+ InMemorySandboxFilesystem filesystem = new InMemorySandboxFilesystem();
+ filesystem.files.put("notes.txt", "old value".getBytes(StandardCharsets.UTF_8));
+ filesystem.uploadError = "permission denied";
+
+ EditResult result = filesystem.edit(RT, "notes.txt", "old", "new", false);
+
+ assertFalse(result.isSuccess());
+ assertEquals("Error editing file 'notes.txt': permission denied", result.error());
+ }
+
+ @Test
+ void edit_emptyUploadResponseReturnsTransferError() {
+ InMemorySandboxFilesystem filesystem = new InMemorySandboxFilesystem();
+ filesystem.files.put("notes.txt", "old value".getBytes(StandardCharsets.UTF_8));
+ filesystem.uploadNoResponse = true;
+
+ EditResult result = filesystem.edit(RT, "notes.txt", "old", "new", false);
+
+ assertFalse(result.isSuccess());
+ assertEquals(
+ "Error editing file 'notes.txt': upload returned no response", result.error());
+ }
}
// ================================================================
@@ -255,6 +479,72 @@ public List downloadFiles(
}
}
+ private static final class InMemorySandboxFilesystem extends BaseSandboxFilesystem {
+
+ final Map files = new HashMap<>();
+ ExecuteResponse executeResponse = new ExecuteResponse("", 0, false);
+ String uploadError;
+ String downloadError;
+ String lastCommand;
+ boolean executeCalled;
+ boolean uploadNoResponse;
+ boolean downloadNoResponse;
+ boolean downloadNullContent;
+
+ @Override
+ public String id() {
+ return "in-memory";
+ }
+
+ @Override
+ public ExecuteResponse execute(
+ RuntimeContext runtimeContext, String command, Integer timeoutSeconds) {
+ executeCalled = true;
+ lastCommand = command;
+ return executeResponse;
+ }
+
+ @Override
+ public List uploadFiles(
+ RuntimeContext runtimeContext, List> uploads) {
+ if (uploadNoResponse) {
+ return List.of();
+ }
+ if (uploadError != null) {
+ return uploads.stream()
+ .map(upload -> FileUploadResponse.fail(upload.getKey(), uploadError))
+ .toList();
+ }
+ for (Map.Entry upload : uploads) {
+ files.put(upload.getKey(), upload.getValue());
+ }
+ return uploads.stream()
+ .map(upload -> FileUploadResponse.success(upload.getKey()))
+ .toList();
+ }
+
+ @Override
+ public List downloadFiles(
+ RuntimeContext runtimeContext, List paths) {
+ if (downloadNoResponse) {
+ return List.of();
+ }
+ return paths.stream()
+ .map(
+ path ->
+ downloadError != null
+ ? FileDownloadResponse.fail(path, downloadError)
+ : downloadNullContent
+ ? FileDownloadResponse.success(path, null)
+ : files.containsKey(path)
+ ? FileDownloadResponse.success(
+ path, files.get(path))
+ : FileDownloadResponse.fail(
+ path, "file_not_found"))
+ .toList();
+ }
+ }
+
private static final class LocalShellSandboxFilesystem extends BaseSandboxFilesystem {
@Override
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 17f4f7d17..aeb4ab2e9 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
@@ -17,6 +17,7 @@
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.assertTrue;
import io.agentscope.core.agent.RuntimeContext;
@@ -111,6 +112,9 @@ void uploadFiles_fallsBackToExecForUnsupportedPaths() {
assertTrue(responses.get(0).isSuccess());
assertTrue(sandbox.uploaded.isEmpty());
+ assertFalse(sandbox.lastCommand.contains("dirname"));
+ assertFalse(sandbox.lastCommand.contains("$("));
+ assertTrue(sandbox.lastCommand.contains("mkdir -p '/etc'"));
assertTrue(sandbox.lastCommand.contains("base64 -d > '/etc/other.txt'"));
}
diff --git a/docs/v2/en/docs/harness/sandbox.md b/docs/v2/en/docs/harness/sandbox.md
index 4ebd86eb8..039a64635 100644
--- a/docs/v2/en/docs/harness/sandbox.md
+++ b/docs/v2/en/docs/harness/sandbox.md
@@ -203,15 +203,14 @@ The image must provide:
| Text/search | `sed`, `grep` (with `-rHnF`, `--include`), `find` | `read_file` pagination, `grep_files`, `glob_files` |
| Metadata | GNU-style `stat -c` (not BSD `stat -f`) | `list_files`, `glob_files` |
| Archive/encoding | `tar`, `base64` (encode + `-d` decode) | snapshot persist/hydrate, file upload/download |
-| Interpreter | `python3` | `edit_file` (exact string replacement) |
| Filesystem | writable workspace root (default `/workspace`) | everything |
-Images based on `ubuntu:24.04` or `debian` qualify out of the box (`python3` may need installing); `alpine` (BusyBox `stat` / `grep` behave differently) and distroless images do **not**.
+Images based on `ubuntu:24.04` or `debian` qualify out of the box; `alpine` (BusyBox `stat` / `grep` behave differently) and distroless images do **not**.
Quick conformance check (run inside the image; all must succeed):
```bash
-sh -c 'echo ok' && python3 --version && tar --version \
+sh -c 'echo ok' && tar --version \
&& printf x | base64 | base64 -d && stat -c %Y /tmp && grep -rHnF --include='*.txt' x /tmp; true
```
diff --git a/docs/v2/zh/docs/harness/sandbox.md b/docs/v2/zh/docs/harness/sandbox.md
index b4e89c066..fba8f6e5b 100644
--- a/docs/v2/zh/docs/harness/sandbox.md
+++ b/docs/v2/zh/docs/harness/sandbox.md
@@ -202,15 +202,14 @@ SandboxContext callCtx = SandboxContext.builder()
| 文本/查找 | `sed` `grep`(支持 `-rHnF` `--include`)`find` | `read_file` 分页、`grep_files`、`glob_files` |
| 元数据 | GNU 风格 `stat -c`(非 BSD `stat -f`) | `list_files`、`glob_files` |
| 归档/编码 | `tar`、`base64`(编码 + `-d` 解码) | 快照持久化/恢复、文件上传下载 |
-| 解释器 | `python3` | `edit_file`(精确字符串替换) |
| 文件系统 | 工作区根目录(默认 `/workspace`)可写 | 全部 |
-以 `ubuntu:24.04`、`debian` 为基础的镜像天然满足(`python3` 可能需额外安装);`alpine`(BusyBox `stat` / `grep` 行为不同)和 distroless 镜像**不满足**。
+以 `ubuntu:24.04`、`debian` 为基础的镜像天然满足;`alpine`(BusyBox `stat` / `grep` 行为不同)和 distroless 镜像**不满足**。
快速自检(在镜像内执行,全部成功即基本达标):
```bash
-sh -c 'echo ok' && python3 --version && tar --version \
+sh -c 'echo ok' && tar --version \
&& printf x | base64 | base64 -d && stat -c %Y /tmp && grep -rHnF --include='*.txt' x /tmp; true
```