diff --git a/agentscope-core/src/main/java/io/agentscope/core/skill/SkillBox.java b/agentscope-core/src/main/java/io/agentscope/core/skill/SkillBox.java index 33f6d007f..2642fc1b9 100644 --- a/agentscope-core/src/main/java/io/agentscope/core/skill/SkillBox.java +++ b/agentscope-core/src/main/java/io/agentscope/core/skill/SkillBox.java @@ -38,12 +38,14 @@ import java.util.Map; import java.util.Set; import java.util.function.Function; +import java.util.regex.Pattern; import org.slf4j.Logger; import org.slf4j.LoggerFactory; public class SkillBox implements StateModule { private static final Logger logger = LoggerFactory.getLogger(SkillBox.class); private static final String BASE64_PREFIX = "base64:"; + private static final Pattern INVALID_FILE_NAME_CHARS = Pattern.compile("[\\\\/:*?\"<>|]"); private final SkillRegistry skillRegistry = new SkillRegistry(); private final AgentSkillPromptProvider skillPromptProvider; @@ -734,7 +736,7 @@ public void uploadSkillFiles() { continue; } - Path skillDir = targetDir.resolve(skillId); + Path skillDir = targetDir.resolve(unifyToSafeSkillId(skillId)); for (String resourcePath : resourcePaths) { if (!filter.accept(resourcePath)) { @@ -777,6 +779,25 @@ public void uploadSkillFiles() { logger.info("Uploaded {} skill files to: {}", fileCount, targetDir); } + /** + * Normalizes a logical skill id to a filesystem-safe directory name. + * + *

Why this is needed: some repositories (e.g. Nacos) build source identifiers like + * {@code nacos:public}. The skill id is composed as {@code name + "_" + source}, so it may + * contain {@code ':'}. On Windows, {@code ':'} is illegal in a path segment (except drive + * letters), and using it in {@link Path#resolve(String)} can fail upload with + * {@code InvalidPathException} or cause file writes to be skipped. + * + *

This method only affects filesystem paths. It does not change the logical + * {@link AgentSkill#getSkillId()} value kept in memory. + */ + static String unifyToSafeSkillId(String skillId) { + if (skillId == null || skillId.isBlank()) { + return "skill"; + } + return INVALID_FILE_NAME_CHARS.matcher(skillId).replaceAll("_"); + } + private static class DefaultSkillFileFilter implements SkillFileFilter { private final Set includeFolders; private final Set includeExtensions; diff --git a/agentscope-core/src/test/java/io/agentscope/core/skill/SkillBoxSafeSkillIdTest.java b/agentscope-core/src/test/java/io/agentscope/core/skill/SkillBoxSafeSkillIdTest.java new file mode 100644 index 000000000..9eaa1d1b9 --- /dev/null +++ b/agentscope-core/src/test/java/io/agentscope/core/skill/SkillBoxSafeSkillIdTest.java @@ -0,0 +1,90 @@ +/* + * 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.core.skill; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import io.agentscope.core.tool.Toolkit; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.HashMap; +import java.util.Map; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +@Tag("unit") +class SkillBoxSafeSkillIdTest { + + @Test + @DisplayName("Should normalize unsafe chars in skill id for filesystem usage") + void testUnifyToSafeSkillId() { + assertEquals("my_skill_nacos_public", SkillBox.unifyToSafeSkillId("my_skill_nacos:public")); + assertEquals("skill", SkillBox.unifyToSafeSkillId("")); + } + + @Test + @DisplayName("Should return default 'skill' when skillId is null") + void testUnifyToSafeSkillIdWithNull() { + assertEquals("skill", SkillBox.unifyToSafeSkillId(null)); + } + + @Test + @DisplayName("Should return default 'skill' when skillId is blank (spaces)") + void testUnifyToSafeSkillIdWithBlankSpaces() { + assertEquals("skill", SkillBox.unifyToSafeSkillId(" ")); + } + + @Test + @DisplayName("Should return default 'skill' when skillId is blank (tabs)") + void testUnifyToSafeSkillIdWithBlankTabs() { + assertEquals("skill", SkillBox.unifyToSafeSkillId("\t\t")); + } + + @Test + @DisplayName("Should return sanitized value when result is not blank") + void testUnifyToSafeSkillIdWithNonBlankResult() { + // Single unsafe char replaced + assertEquals("my_skill", SkillBox.unifyToSafeSkillId("my:skill")); + + // Multiple unsafe chars replaced + assertEquals("skill_name_with_colon", SkillBox.unifyToSafeSkillId("skill:name:with:colon")); + + // No unsafe chars - unchanged + assertEquals("safe_skill_id", SkillBox.unifyToSafeSkillId("safe_skill_id")); + } + + @Test + @DisplayName("Should upload resources under normalized skill id directory") + void testUploadUsesSafeSkillId(@TempDir Path tempDir) throws IOException { + SkillBox skillBox = new SkillBox(new Toolkit()); + skillBox.codeExecution().workDir(tempDir.resolve("work").toString()).withWrite().enable(); + + Map resources = new HashMap<>(); + resources.put("scripts/main.py", "print('ok')"); + AgentSkill skill = new AgentSkill("my_skill", "desc", "content", resources, "nacos:public"); + skillBox.registerSkill(skill); + + skillBox.uploadSkillFiles(); + + Path uploadDir = skillBox.getUploadDir(); + Path expected = uploadDir.resolve("my_skill_nacos_public/scripts/main.py"); + assertTrue(Files.exists(expected)); + } +} diff --git a/agentscope-extensions/agentscope-extensions-nacos/agentscope-extensions-nacos-skill/src/test/java/io/agentscope/core/nacos/skill/SkillBoxWindowsNacosSourceReproTest.java b/agentscope-extensions/agentscope-extensions-nacos/agentscope-extensions-nacos-skill/src/test/java/io/agentscope/core/nacos/skill/SkillBoxWindowsNacosSourceReproTest.java new file mode 100644 index 000000000..686892243 --- /dev/null +++ b/agentscope-extensions/agentscope-extensions-nacos/agentscope-extensions-nacos-skill/src/test/java/io/agentscope/core/nacos/skill/SkillBoxWindowsNacosSourceReproTest.java @@ -0,0 +1,109 @@ +/* + * 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.core.nacos.skill; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.Mockito.when; + +import com.alibaba.nacos.api.ai.AiService; +import com.alibaba.nacos.api.exception.NacosException; +import io.agentscope.core.skill.AgentSkill; +import io.agentscope.core.skill.SkillBox; +import io.agentscope.core.tool.Toolkit; +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.nio.file.Path; +import java.util.zip.ZipEntry; +import java.util.zip.ZipOutputStream; +import org.junit.jupiter.api.DisplayName; +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.extension.ExtendWith; +import org.junit.jupiter.api.io.TempDir; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +/** + * Windows repro for Nacos source ":" issue with real {@link NacosSkillRepository} construction. + */ +@ExtendWith(MockitoExtension.class) +class SkillBoxWindowsNacosSourceReproTest { + + @Mock private AiService aiService; + + @Test + @EnabledOnOs(OS.WINDOWS) + @DisplayName("[Windows] real NacosSkillRepository source uploads via safe skill id path") + void uploadSucceedsWithRealNacosSkillRepository(@TempDir Path tempDir) + throws NacosException, IOException { + NacosSkillRepository repository = new NacosSkillRepository(aiService, "public"); + when(aiService.downloadSkillZip("repro")) + .thenReturn(createSkillZip("repro", "desc", "content", "scripts/main.py", "ok")); + + AgentSkill skill = repository.getSkill("repro"); + assertEquals("nacos:public", skill.getSource()); + + SkillBox skillBox = new SkillBox(new Toolkit()); + skillBox.registerSkill(skill); + skillBox.codeExecution() + .workDir(tempDir.resolve("work").toString()) + .withShell() + .withRead() + .withWrite() + .enable(); + + skillBox.uploadSkillFiles(); + + // SkillBox normalizes skillId for filesystem safety (':' -> '_'). + Path expected = skillBox.getUploadDir().resolve("repro_nacos_public/scripts/main.py"); + assertTrue(expected.toFile().exists(), "Resource should be uploaded under normalized path"); + } + + private static byte[] createSkillZip( + String name, + String description, + String skillContent, + String resourcePath, + String resourceContent) + throws IOException { + String root = "skill-package"; + String skillMd = + "---\n" + + "name: " + + name + + "\n" + + "description: " + + description + + "\n" + + "---\n" + + skillContent; + try (ByteArrayOutputStream baos = new ByteArrayOutputStream(); + ZipOutputStream zos = new ZipOutputStream(baos)) { + zos.putNextEntry(new ZipEntry(root + "/SKILL.md")); + zos.write(skillMd.getBytes()); + zos.closeEntry(); + + zos.putNextEntry(new ZipEntry(root + "/" + resourcePath)); + zos.write(resourceContent.getBytes()); + zos.closeEntry(); + + zos.finish(); + return baos.toByteArray(); + } + } +}