diff --git a/mateclaw-server/src/main/java/vip/mate/tool/builtin/CodeExecuteTool.java b/mateclaw-server/src/main/java/vip/mate/tool/builtin/CodeExecuteTool.java index 7919d67e..fd890e7d 100644 --- a/mateclaw-server/src/main/java/vip/mate/tool/builtin/CodeExecuteTool.java +++ b/mateclaw-server/src/main/java/vip/mate/tool/builtin/CodeExecuteTool.java @@ -19,6 +19,7 @@ import vip.mate.skill.runtime.SkillRuntimeService; import vip.mate.skill.runtime.SkillScriptExecutionService; import vip.mate.skill.runtime.model.ResolvedSkill; import vip.mate.skill.secret.SkillSecretService; +import vip.mate.tool.document.GeneratedFileCache; import vip.mate.tool.guard.WorkspacePathGuard; import java.nio.file.Files; @@ -28,6 +29,8 @@ import java.util.Collections; import java.util.List; import java.util.Map; import java.util.Set; +import java.util.stream.Collectors; +import java.util.stream.Stream; /** * Built-in tool: execute LLM-generated source code inline. @@ -58,6 +61,13 @@ public class CodeExecuteTool { private final SkillScriptExecutionService executionService; private final SkillSecretService skillSecretService; private final ObjectMapper objectMapper; + private final GeneratedFileCache generatedFileCache; + + /** Artifact-surfacing bounds: keep heap and noise in check (best-effort feature). */ + private static final int ARTIFACT_SCAN_DEPTH = 4; + private static final int MAX_ARTIFACTS = 8; + private static final long MAX_ARTIFACT_BYTES = 20L * 1024 * 1024; + private static final long MAX_TOTAL_ARTIFACT_BYTES = 48L * 1024 * 1024; @Lazy @Autowired @@ -79,7 +89,9 @@ public class CodeExecuteTool { a JSON array for multiple args, or plain text for a single argument. - timeoutSeconds: optional, default 30, max 300. - Returns: JSON with exitCode, stdout, stderr. + Returns: JSON with exitCode, stdout, stderr, and (when the run wrote files) + a generatedFiles array of [name](url) download links. When present, echo + those links in your reply so the user can download the files you produced. Security: dangerous operations trigger security approval. The server's own secret environment variables are not exposed to the code. @@ -149,16 +161,104 @@ public class CodeExecuteTool { Long timeout = timeoutSeconds != null ? timeoutSeconds.longValue() : null; List argList = normalizeArgs(args); + long runStart = System.currentTimeMillis(); try { SkillScriptExecutionService.ScriptResult result = executionService.executeCode(language, code, workingDir, argList, envVars, timeout); - return formatResult(result); + // Surface any files the run wrote as one-click downloads so the user can + // grab generated artifacts (xlsx / csv / images / …) without the model + // having to call send_file or echo a server path. + List fileLinks = collectArtifactLinks(workingDir, runStart, ctx); + return formatResult(result, fileLinks); } catch (Exception e) { log.error("[CodeExecute] Execution failed: {}", e.getMessage()); return formatError("Execution failed: " + e.getMessage()); } } + /** + * Best-effort: register files created or modified in {@code workingDir} during + * this run into the generated-file cache and return their download links in the + * {@code [name](url)} markdown form the chat layer scans for. Never throws — a + * failure here must not fail the code run. + */ + List collectArtifactLinks(Path workingDir, long sinceMillis, @Nullable ToolContext ctx) { + if (workingDir == null || !Files.isDirectory(workingDir)) { + return List.of(); + } + List links = new ArrayList<>(); + long totalBytes = 0L; + try (Stream walk = Files.walk(workingDir, ARTIFACT_SCAN_DEPTH)) { + List candidates = walk + .filter(Files::isRegularFile) + .filter(p -> !isNoiseArtifact(p)) + .filter(p -> modifiedSince(p, sinceMillis)) + .limit(200) + .toList(); + for (Path p : candidates) { + if (links.size() >= MAX_ARTIFACTS) { + break; + } + try { + long size = Files.size(p); + if (size <= 0 || size > MAX_ARTIFACT_BYTES || totalBytes + size > MAX_TOTAL_ARTIFACT_BYTES) { + continue; + } + byte[] bytes = Files.readAllBytes(p); + totalBytes += size; + String name = p.getFileName().toString(); + String id = generatedFileCache.put(bytes, name, probeMime(p, name)); + links.add("[" + name + "](" + generatedFileCache.downloadUrl(id, ctx) + ")"); + } catch (Exception perFile) { + log.debug("[CodeExecute] skip artifact {}: {}", p, perFile.getMessage()); + } + } + } catch (Exception e) { + log.debug("[CodeExecute] artifact scan failed for {}: {}", workingDir, e.getMessage()); + } + return links; + } + + private static boolean modifiedSince(Path p, long sinceMillis) { + try { + // 1s slack absorbs filesystem mtime granularity. + return Files.getLastModifiedTime(p).toMillis() >= sinceMillis - 1000L; + } catch (Exception e) { + return false; + } + } + + /** Skip hidden files, dependency/cache dirs, and obvious scratch/log files. */ + private static boolean isNoiseArtifact(Path p) { + for (Path seg : p) { + String s = seg.toString(); + if (s.startsWith(".") || s.equals("__pycache__") || s.equals("node_modules")) { + return true; + } + } + String name = p.getFileName().toString().toLowerCase(); + return name.endsWith(".pyc") || name.endsWith(".tmp") || name.endsWith(".lock") + || name.endsWith(".log") || name.endsWith(".class"); + } + + private static String probeMime(Path p, String name) { + try { + String mime = Files.probeContentType(p); + if (mime != null && !mime.isBlank()) { + return mime; + } + } catch (Exception ignore) { + // fall through to extension default + } + String lower = name.toLowerCase(); + if (lower.endsWith(".xlsx")) return "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"; + if (lower.endsWith(".docx")) return "application/vnd.openxmlformats-officedocument.wordprocessingml.document"; + if (lower.endsWith(".pptx")) return "application/vnd.openxmlformats-officedocument.presentationml.presentation"; + if (lower.endsWith(".csv")) return "text/csv"; + if (lower.endsWith(".pdf")) return "application/pdf"; + return "application/octet-stream"; + } + /** * Decode the JSON-encoded {@code args} string into a positional argument list, * mirroring {@code SkillScriptTool.normalizeArgs}: a JSON array becomes one @@ -193,12 +293,19 @@ public class CodeExecuteTool { return List.of(trimmed); } - private String formatResult(SkillScriptExecutionService.ScriptResult result) { + private String formatResult(SkillScriptExecutionService.ScriptResult result, List fileLinks) { + // generatedFiles carries [name](url) markdown so the chat layer surfaces the + // artifacts as one-click downloads, and the model can echo them to the user. + String filesField = (fileLinks == null || fileLinks.isEmpty()) ? "" : + ",\n \"generatedFiles\": [" + + fileLinks.stream().map(this::jsonEscape).collect(Collectors.joining(", ")) + + "]"; return String.format( - "{\n \"exitCode\": %d,\n \"stdout\": %s,\n \"stderr\": %s\n}", + "{\n \"exitCode\": %d,\n \"stdout\": %s,\n \"stderr\": %s%s\n}", result.getExitCode(), jsonEscape(result.getStdout()), - jsonEscape(result.getStderr()) + jsonEscape(result.getStderr()), + filesField ); } diff --git a/mateclaw-server/src/test/java/vip/mate/tool/builtin/CodeExecuteToolArgsTest.java b/mateclaw-server/src/test/java/vip/mate/tool/builtin/CodeExecuteToolArgsTest.java index 6444d4a1..38e62ff0 100644 --- a/mateclaw-server/src/test/java/vip/mate/tool/builtin/CodeExecuteToolArgsTest.java +++ b/mateclaw-server/src/test/java/vip/mate/tool/builtin/CodeExecuteToolArgsTest.java @@ -24,7 +24,7 @@ class CodeExecuteToolArgsTest { /** Unused collaborators are null — {@code normalizeArgs} only needs the mapper. */ private final CodeExecuteTool tool = - new CodeExecuteTool(null, null, null, objectMapper); + new CodeExecuteTool(null, null, null, objectMapper, null); @Test @DisplayName("null / blank / empty-array args yield no argument list") diff --git a/mateclaw-server/src/test/java/vip/mate/tool/builtin/CodeExecuteToolArtifactTest.java b/mateclaw-server/src/test/java/vip/mate/tool/builtin/CodeExecuteToolArtifactTest.java new file mode 100644 index 00000000..eb44bab3 --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/tool/builtin/CodeExecuteToolArtifactTest.java @@ -0,0 +1,107 @@ +package vip.mate.tool.builtin; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import vip.mate.tool.document.GeneratedFileCache; + +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Comparator; +import java.util.List; +import java.util.Optional; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * After a code run, files it wrote to the working dir should surface as + * one-click downloads (issue #191): the tool registers them in the + * generated-file cache and returns {@code [name](url)} links the chat layer + * already scans for. Pre-existing files, scratch/noise files, and oversized + * files must be left out. + */ +class CodeExecuteToolArtifactTest { + + // Mirror of ChatController.GENERATED_FILE_LINK_PATTERN so we assert the links + // are in the exact shape the chat layer extracts. + private static final Pattern LINK = Pattern.compile( + "\\[([^\\]]+)\\]\\(((?:https?://[^/\\s)\\]]+)?/api/v1/files/generated/[A-Za-z0-9-]+)\\)"); + + private Path tmp; + private Path cacheDir; + + private CodeExecuteTool newTool(GeneratedFileCache cache) { + // Only generatedFileCache is exercised here; the other collaborators are + // unused by collectArtifactLinks. + return new CodeExecuteTool(null, null, null, null, cache); + } + + @AfterEach + void cleanup() throws Exception { + for (Path root : new Path[]{tmp, cacheDir}) { + if (root != null && Files.exists(root)) { + try (var s = Files.walk(root)) { + s.sorted(Comparator.reverseOrder()).forEach(p -> { + try { Files.deleteIfExists(p); } catch (Exception ignore) { } + }); + } + } + } + } + + @Test + @DisplayName("Files written during the run surface as download links; old/noise files do not") + void surfacesNewlyWrittenFiles() throws Exception { + tmp = Files.createTempDirectory("codeexec-artifacts-"); + cacheDir = Files.createTempDirectory("codeexec-cache-"); + GeneratedFileCache cache = new GeneratedFileCache(cacheDir); + + // Pre-existing file, modified well before the run window — must be ignored. + Path old = Files.write(tmp.resolve("old.txt"), "old".getBytes()); + Files.setLastModifiedTime(old, java.nio.file.attribute.FileTime.fromMillis(1_000L)); + + long runStart = System.currentTimeMillis(); + Thread.sleep(5); + + // Files produced "by the run". + byte[] xlsx = "PK fake-xlsx-bytes".getBytes(); + Files.write(tmp.resolve("report.xlsx"), xlsx); + Files.write(tmp.resolve("data.csv"), "a,b\n1,2\n".getBytes()); + // Noise that must be filtered out. + Files.write(tmp.resolve("scratch.tmp"), "x".getBytes()); + Files.write(tmp.resolve(".hidden"), "x".getBytes()); + + List links = newTool(cache).collectArtifactLinks(tmp, runStart, null); + + // report.xlsx + data.csv only. + assertEquals(2, links.size(), "expected the two real artifacts, got: " + links); + assertTrue(links.stream().anyMatch(l -> l.contains("report.xlsx"))); + assertTrue(links.stream().anyMatch(l -> l.contains("data.csv"))); + assertFalse(links.stream().anyMatch(l -> l.contains("old.txt")), "pre-existing file must not surface"); + assertFalse(links.stream().anyMatch(l -> l.contains("scratch.tmp")), ".tmp must not surface"); + assertFalse(links.stream().anyMatch(l -> l.contains(".hidden")), "hidden file must not surface"); + + // Each link is in the exact shape the chat layer extracts, and the bytes + // round-trip through the cache (so the download endpoint can serve them). + for (String link : links) { + Matcher m = LINK.matcher(link); + assertTrue(m.matches(), "link not in extractable form: " + link); + String id = m.group(2).substring(m.group(2).lastIndexOf('/') + 1); + Optional entry = cache.get(id); + assertTrue(entry.isPresent(), "cached artifact must be retrievable: " + link); + } + } + + @Test + @DisplayName("Null / non-existent working dir yields no links and does not throw") + void nullWorkingDirIsSafe() throws Exception { + cacheDir = Files.createTempDirectory("codeexec-cache-"); + CodeExecuteTool tool = newTool(new GeneratedFileCache(cacheDir)); + assertTrue(tool.collectArtifactLinks(null, 0L, null).isEmpty()); + assertTrue(tool.collectArtifactLinks(Path.of("/no/such/dir/xyz"), 0L, null).isEmpty()); + } +}