diff --git a/agentscope-core/src/main/java/io/agentscope/core/skill/util/SkillFileSystemHelper.java b/agentscope-core/src/main/java/io/agentscope/core/skill/util/SkillFileSystemHelper.java index 554fd0a89..614d1866b 100644 --- a/agentscope-core/src/main/java/io/agentscope/core/skill/util/SkillFileSystemHelper.java +++ b/agentscope-core/src/main/java/io/agentscope/core/skill/util/SkillFileSystemHelper.java @@ -21,6 +21,7 @@ import java.io.IOException; import java.nio.charset.MalformedInputException; import java.nio.charset.StandardCharsets; +import java.nio.file.DirectoryStream; import java.nio.file.Files; import java.nio.file.Path; import java.util.ArrayList; @@ -353,36 +354,72 @@ public static Thread registerTempDirectoryCleanup(Path directory) { return hook; } - private static Map loadResources(Path skillDir, Path skillFile) - throws IOException { + private static Map loadResources(Path skillDir, Path skillFile) { Map resources = new HashMap<>(); - try (Stream paths = Files.walk(skillDir)) { - paths.filter(Files::isRegularFile) - .filter(p -> !p.equals(skillFile)) - .forEach( - p -> { - String relativePath = - skillDir.relativize(p).toString().replace('\\', '/'); - try { - String content = Files.readString(p, StandardCharsets.UTF_8); - resources.put(relativePath, content); - } catch (MalformedInputException e) { - try { - byte[] bytes = Files.readAllBytes(p); - String base64 = Base64.getEncoder().encodeToString(bytes); - resources.put(relativePath, "base64:" + base64); - } catch (IOException ex) { - logger.warn( - "Failed to read binary resource file: {}", p, ex); - } - } catch (IOException e) { - logger.warn("Failed to read resource file: {}", p, e); - } - }); - } + // Recursive traversal starting from the root directory + collectResources(skillDir, skillDir, skillFile, resources); return resources; } + private static void collectResources( + Path currentDir, Path skillDir, Path skillFile, Map resources) { + try (DirectoryStream stream = Files.newDirectoryStream(currentDir)) { + for (Path entry : stream) { + if (Files.isDirectory(entry)) { + if (!entry.getFileName().toString().startsWith(".")) { + collectResources(entry, skillDir, skillFile, resources); + } + } else { + if (isValidResource(entry, skillFile)) { + readAndPutResource(entry, skillDir, resources); + } + } + } + } catch (IOException e) { + logger.warn("Failed to open directory: {}", currentDir, e); + } + } + + private static void readAndPutResource( + Path file, Path skillDir, Map resources) { + String relativePath = skillDir.relativize(file).toString().replace('\\', '/'); + try { + String content = Files.readString(file, StandardCharsets.UTF_8); + resources.put(relativePath, content); + } catch (MalformedInputException e) { + try { + byte[] bytes = Files.readAllBytes(file); + String base64 = Base64.getEncoder().encodeToString(bytes); + resources.put(relativePath, "base64:" + base64); + } catch (IOException ex) { + logger.warn("Failed to read binary resource file: {}", file, ex); + } + } catch (IOException e) { + logger.warn("Failed to read resource file: {}", file, e); + } + } + + private static boolean isValidResource(Path file, Path skillFile) { + if (file.equals(skillFile)) { + return false; + } + + if (!Files.isRegularFile(file) || !Files.isReadable(file)) { + return false; + } + + if (file.getFileName().toString().startsWith(".")) { + return false; + } + + try { + return !Files.isHidden(file); + } catch (IOException e) { + logger.warn("Failed to check attributes for file: {}", file, e); + return true; + } + } + private static Optional findSkillDirectoryByName(Path baseDir, String skillName) { if (skillName == null || skillName.isEmpty()) { return Optional.empty(); diff --git a/agentscope-core/src/test/java/io/agentscope/core/skill/util/SkillFileSystemHelperTest.java b/agentscope-core/src/test/java/io/agentscope/core/skill/util/SkillFileSystemHelperTest.java index 87707e2f8..10948c72c 100644 --- a/agentscope-core/src/test/java/io/agentscope/core/skill/util/SkillFileSystemHelperTest.java +++ b/agentscope-core/src/test/java/io/agentscope/core/skill/util/SkillFileSystemHelperTest.java @@ -35,7 +35,12 @@ import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Tag; import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.condition.EnabledOnOs; +import org.junit.jupiter.api.condition.OS; import org.junit.jupiter.api.io.TempDir; +import org.mockito.ArgumentMatchers; +import org.mockito.MockedStatic; +import org.mockito.Mockito; /** * Unit tests for SkillFileSystemHelper. @@ -255,6 +260,126 @@ void testSaveSkills_DecodesBase64ToBinary() throws IOException { assertEquals("plain text", Files.readString(savedText, StandardCharsets.UTF_8)); } + @Test + @DisplayName("Should load normal readable files") + void shouldLoadNormalResourceFiles() throws IOException { + createSampleSkill("normal-skill", "Test Normal", "Test content"); + Path skillDir = skillsBaseDir.resolve("normal-skill"); + Path normalFile = skillDir.resolve("normal_resource.txt"); + Files.writeString(normalFile, "normal", StandardCharsets.UTF_8); + + AgentSkill skill = + SkillFileSystemHelper.loadSkill(skillsBaseDir, "normal-skill", "test-source"); + + assertNotNull(skill); + assertTrue( + skill.getResources().containsKey("normal_resource.txt"), + "Normal file should be loaded"); + } + + @Test + @DisplayName("Should filter out unreadable files") + void shouldFilterUnreadableFiles() throws IOException { + createSampleSkill("unreadable-skill", "Test Unreadable", "Test content"); + Path skillDir = skillsBaseDir.resolve("unreadable-skill"); + Path unreadableFile = skillDir.resolve("secret.txt"); + Files.writeString(unreadableFile, "secret", StandardCharsets.UTF_8); + + try (MockedStatic mockedFiles = + Mockito.mockStatic(Files.class, Mockito.CALLS_REAL_METHODS)) { + mockedFiles + .when(() -> Files.isReadable(ArgumentMatchers.any(Path.class))) + .thenAnswer( + invocation -> { + Path p = invocation.getArgument(0); + if (p.getFileName().toString().equals("secret.txt")) return false; + return invocation.callRealMethod(); + }); + + AgentSkill skill = + SkillFileSystemHelper.loadSkill( + skillsBaseDir, "unreadable-skill", "test-source"); + assertFalse( + skill.getResources().containsKey("secret.txt"), + "Unreadable file should be filtered out"); + } + } + + @Test + @DisplayName("Should explicitly filter out dot-files and files within dot-directories") + void shouldFilterDotFilesAndDirectories() throws IOException { + createSampleSkill("dot-skill", "Test Dot Files", "Test content"); + Path skillDir = skillsBaseDir.resolve("dot-skill"); + + Path dotFile = skillDir.resolve(".DS_Store"); + Files.writeString(dotFile, "garbage", StandardCharsets.UTF_8); + + Path dotDir = skillDir.resolve(".hidden_dir"); + Files.createDirectories(dotDir); + Path dotDirFile = dotDir.resolve("config.txt"); + Files.writeString(dotDirFile, "hidden config", StandardCharsets.UTF_8); + + AgentSkill skill = + SkillFileSystemHelper.loadSkill(skillsBaseDir, "dot-skill", "test-source"); + + assertFalse(skill.getResources().containsKey(".DS_Store"), "Dot file should be filtered"); + assertFalse( + skill.getResources().containsKey(".hidden_dir/config.txt"), + "File inside dot directory should be filtered"); + } + + @Test + @DisplayName("Should default to loading the file if isHidden() throws IOException") + void shouldHandleIOExceptionDuringAttributeCheck() throws IOException { + createSampleSkill("io-exception-skill", "Test IO Exception", "Test content"); + Path skillDir = skillsBaseDir.resolve("io-exception-skill"); + Path triggerFile = skillDir.resolve("error_trigger.txt"); + Files.writeString(triggerFile, "trigger", StandardCharsets.UTF_8); + + try (MockedStatic mockedFiles = + Mockito.mockStatic(Files.class, Mockito.CALLS_REAL_METHODS)) { + mockedFiles + .when(() -> Files.isHidden(ArgumentMatchers.any(Path.class))) + .thenAnswer( + invocation -> { + Path p = invocation.getArgument(0); + if (p.getFileName().toString().equals("error_trigger.txt")) { + throw new IOException("Simulated IO Exception for testing"); + } + return invocation.callRealMethod(); + }); + + AgentSkill skill = + SkillFileSystemHelper.loadSkill( + skillsBaseDir, "io-exception-skill", "test-source"); + assertTrue( + skill.getResources().containsKey("error_trigger.txt"), + "File causing IOException should default to being loaded"); + } + } + + @Test + @EnabledOnOs(OS.WINDOWS) + @DisplayName("Should filter OS-level hidden files on Windows") + void shouldFilterOsHiddenFilesOnWindows() throws IOException { + createSampleSkill("os-hidden-skill", "Test OS Hidden", "Test content"); + Path skillDir = skillsBaseDir.resolve("os-hidden-skill"); + + Path osHiddenFile = skillDir.resolve("os_hidden_file.txt"); + Files.writeString(osHiddenFile, "hidden data", StandardCharsets.UTF_8); + + try { + Files.setAttribute(osHiddenFile, "dos:hidden", true); + } catch (Exception ignored) { + } + + AgentSkill skill = + SkillFileSystemHelper.loadSkill(skillsBaseDir, "os-hidden-skill", "test-source"); + assertFalse( + skill.getResources().containsKey("os_hidden_file.txt"), + "OS hidden file should be filtered out on Windows"); + } + private void createSampleSkill(String name, String description, String content) throws IOException { Path skillDir = skillsBaseDir.resolve(name);