diff --git a/mateclaw-server/src/main/java/vip/mate/tool/builtin/DocumentExtractTool.java b/mateclaw-server/src/main/java/vip/mate/tool/builtin/DocumentExtractTool.java index 4763ab9b..aa37e770 100644 --- a/mateclaw-server/src/main/java/vip/mate/tool/builtin/DocumentExtractTool.java +++ b/mateclaw-server/src/main/java/vip/mate/tool/builtin/DocumentExtractTool.java @@ -8,10 +8,12 @@ import org.springframework.ai.tool.annotation.Tool; import org.springframework.ai.tool.annotation.ToolParam; import org.springframework.lang.Nullable; import org.springframework.stereotype.Component; +import vip.mate.tool.guard.WorkspacePathGuard; import java.io.*; import java.nio.file.Files; import java.nio.file.Path; +import java.nio.file.Paths; import java.util.ArrayList; import java.util.List; import java.util.concurrent.TimeUnit; @@ -75,24 +77,54 @@ public class DocumentExtractTool { // ChatOrigin so the workspace boundary check honors per-agent basePath. @Nullable ToolContext ctx) { + Path path; + try { + path = WorkspacePathGuard.validatePath(filePath, ctx); + } catch (IllegalArgumentException e) { + // Sandbox rejected the literal path. Try chat-upload basename + // resolution before surfacing the boundary error. + Path attachment = ChatUploadResolver.resolve(filePath); + if (attachment == null) { + return errorResult(filePath, e.getMessage(), new ArrayList<>()); + } + path = attachment; + } + return extractResolved(filePath, path, options); + } + + /** + * Internal, sandbox-exempt extraction for server-managed file paths. + *

+ * The wiki ingest pipeline stages an uploaded raw material under its own + * upload directory and feeds that stored path straight back here. The path + * is produced by the server, never by the model, so the workspace boundary + * guard — whose job is to stop the LLM reading arbitrary disk locations — + * must not apply: the wiki upload dir is a sibling of the global sandbox root + * and would otherwise be rejected as "outside workspace boundary", surfacing + * to the user as "No text content available". Callers must pass a path the + * server itself produced, not anything derived from model output. + * + * @param filePath absolute, server-controlled path to the staged document + * @param options same options JSON accepted by {@link #extract_document_text} + */ + public String extractTrustedDocument(String filePath, String options) { + Path path = Paths.get(filePath).toAbsolutePath().normalize(); + return extractResolved(filePath, path, options); + } + + /** + * Shared extraction body running on an already-resolved {@link Path}: detects + * the document type and drives the per-format extractor chain. Both the + * sandbox-guarded {@link #extract_document_text} tool entry and the trusted + * {@link #extractTrustedDocument} internal entry funnel through here so the + * extraction logic stays in one place. + */ + private String extractResolved(String filePath, Path path, String options) { JSONObject result = new JSONObject(); result.set("filePath", filePath); List attempts = new ArrayList<>(); try { - Path path; - try { - path = vip.mate.tool.guard.WorkspacePathGuard.validatePath(filePath, ctx); - } catch (IllegalArgumentException e) { - // Sandbox rejected the literal path. Try chat-upload basename - // resolution before surfacing the boundary error. - Path attachment = ChatUploadResolver.resolve(filePath); - if (attachment == null) { - return errorResult(filePath, e.getMessage(), attempts); - } - path = attachment; - } - if (!Files.exists(path)) { // The user-uploaded chat attachment is rendered to the LLM as // "[附件] foo.docx" without its stored path, and Chinese / non-ASCII diff --git a/mateclaw-server/src/main/java/vip/mate/wiki/service/WikiRawMaterialService.java b/mateclaw-server/src/main/java/vip/mate/wiki/service/WikiRawMaterialService.java index e1792b3c..e60ad96d 100644 --- a/mateclaw-server/src/main/java/vip/mate/wiki/service/WikiRawMaterialService.java +++ b/mateclaw-server/src/main/java/vip/mate/wiki/service/WikiRawMaterialService.java @@ -633,7 +633,10 @@ public class WikiRawMaterialService { // 二进制文件:调用 DocumentExtractTool 提取 if (entity.getSourcePath() != null && !entity.getSourcePath().isBlank()) { try { - String result = documentExtractTool.extract_document_text(entity.getSourcePath(), null, null); + // Server-managed path (staged under the wiki upload dir): use the + // sandbox-exempt entry so the workspace boundary guard does not + // reject the upload dir as "outside workspace boundary". + String result = documentExtractTool.extractTrustedDocument(entity.getSourcePath(), null); JSONObject json = JSONUtil.parseObj(result); if (json.getBool("success", false)) { String text = json.getStr("text"); diff --git a/mateclaw-server/src/test/java/vip/mate/tool/builtin/DocumentExtractToolTrustedPathTest.java b/mateclaw-server/src/test/java/vip/mate/tool/builtin/DocumentExtractToolTrustedPathTest.java new file mode 100644 index 00000000..d28f567d --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/tool/builtin/DocumentExtractToolTrustedPathTest.java @@ -0,0 +1,120 @@ +package vip.mate.tool.builtin; + +import cn.hutool.json.JSONObject; +import cn.hutool.json.JSONUtil; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.condition.DisabledOnOs; +import org.junit.jupiter.api.condition.OS; +import org.junit.jupiter.api.io.TempDir; +import vip.mate.tool.guard.WorkspacePathGuard; + +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.zip.ZipEntry; +import java.util.zip.ZipOutputStream; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Regression coverage for issue #323: uploading a .docx (or any binary file) to + * the wiki failed with "No text content available". + * + *

Root cause: the wiki ingest pipeline stages uploads under its own upload + * directory (default {@code ./data/wiki-uploads}) and feeds that path back into + * {@link DocumentExtractTool} for text extraction. With the workspace sandbox + * enabled (the default), the global fallback root is {@code ./data/workspace} — + * a sibling of the upload dir. The boundary guard therefore rejected the + * server's own staged path as "outside workspace boundary", the extractor + * returned {@code success=false}, and the wiki fell back to a null body. + * + *

The fix routes the internal, server-controlled path through + * {@link DocumentExtractTool#extractTrustedDocument} which skips the LLM-oriented + * boundary guard. These tests pin both halves: the guarded tool entry still + * rejects an out-of-sandbox path (demonstrating the bug), and the trusted entry + * extracts it successfully (verifying the fix). + */ +@DisabledOnOs(OS.WINDOWS) // POSIX-style absolute paths / sandbox roots in these cases +class DocumentExtractToolTrustedPathTest { + + private static final String TOKEN = "REGRESSION_TOKEN_323"; + + private final DocumentExtractTool tool = new DocumentExtractTool(); + + @TempDir + Path sandboxRoot; // stands in for ./data/workspace + + @TempDir + Path uploadDir; // stands in for ./data/wiki-uploads (a sibling, outside the sandbox) + + private Path docx; + + @BeforeEach + void setup() throws Exception { + // Simulate the out-of-the-box state: sandbox enabled with a fallback root, + // no per-conversation workspace configured. + ToolExecutionContext.clear(); + WorkspacePathGuard.setDefaultRoot(sandboxRoot.toString()); + docx = uploadDir.resolve(System.currentTimeMillis() + "_regression-323.docx"); + writeMinimalDocx(docx, TOKEN + " hello world"); + } + + @AfterEach + void teardown() { + ToolExecutionContext.clear(); + WorkspacePathGuard.setDefaultRoot(null); + WorkspacePathGuard.setSkillRoot(null); + } + + @Test + @DisplayName("Guarded tool entry rejects the staged upload path (the #323 failure)") + void guardedEntry_rejectsOutsideSandbox() { + JSONObject result = JSONUtil.parseObj( + tool.extract_document_text(docx.toString(), null, null)); + assertThat(result.getBool("success", false)) + .as("an upload-dir path sits outside the sandbox root and must be blocked by the guard") + .isFalse(); + } + + @Test + @DisplayName("Trusted entry extracts the staged upload path (the #323 fix)") + void trustedEntry_extractsOutsideSandbox() { + JSONObject result = JSONUtil.parseObj( + tool.extractTrustedDocument(docx.toString(), null)); + assertThat(result.getBool("success", false)) + .as("server-managed path must bypass the sandbox and extract successfully") + .isTrue(); + assertThat(result.getStr("text")).contains(TOKEN); + } + + /** + * Write a minimal but valid-enough .docx: a ZIP whose {@code word/document.xml} + * carries the text inside {@code } runs. This is exactly what the pure-Java + * ZIP-XML extractor in {@link DocumentExtractTool} reads, so the test needs no + * external tools (textutil / pandoc / libreoffice) to be installed. + */ + private static void writeMinimalDocx(Path target, String body) throws Exception { + String contentTypes = """ + + + + + """; + String documentXml = """ + + + %s + """.formatted(body); + try (ZipOutputStream zos = new ZipOutputStream(Files.newOutputStream(target))) { + zos.putNextEntry(new ZipEntry("[Content_Types].xml")); + zos.write(contentTypes.getBytes(StandardCharsets.UTF_8)); + zos.closeEntry(); + zos.putNextEntry(new ZipEntry("word/document.xml")); + zos.write(documentXml.getBytes(StandardCharsets.UTF_8)); + zos.closeEntry(); + } + } +}