fix(skill): prevent directory collision for non-ASCII skill names (#255)

Skills with non-ASCII (e.g. Chinese) names collapsed to underscores in the workspace path, so two such skills resolved to the same directory and overwrote each other. Preserve Unicode letters/digits when sanitizing the path so distinct names map to distinct directories. Also write SKILL.md with CREATE_NEW to avoid a TOCTOU race on concurrent uploads, and mount the skills workspace as a named Docker volume so packages survive container restarts.

Closes #254
This commit is contained in:
倪程伟 2026-06-07 20:00:18 +08:00 committed by GitHub
parent f238959856
commit 00b87a4325
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
2 changed files with 38 additions and 6 deletions

View File

@ -109,7 +109,9 @@ services:
- "1455:1455"
volumes:
- server_data:/app/data
- skills_data:/root/.mateclaw/skills
volumes:
mysql_data:
server_data:
skills_data:

View File

@ -47,10 +47,33 @@ public class SkillWorkspaceManager {
}
/**
* 按约定解析 skill 工作区路径{root}/{skillName}/
* 按约定解析 skill 工作区路径{root}/{sanitizedName}-{hash}/
* 路径完全由 skillName 决定不依赖文件系统状态保证确定性
* 同一 skillName 始终返回同一路径不同 skillName 不会碰撞
*/
public Path resolveConventionPath(String skillName) {
return getWorkspaceRoot().resolve(sanitizeName(skillName));
String base = sanitizeNameForFs(skillName);
String hash = Integer.toHexString(skillName.hashCode());
return getWorkspaceRoot().resolve(base + "-" + hash);
}
/**
* 清理文件系统路径不安全字符保留 Unicode 字母及数字的可读性
* 仅移除真正有问题的字符路径分隔符控制字符等
*/
private String sanitizeNameForFs(String name) {
if (name == null || name.isBlank()) {
return "unnamed";
}
// 第一步移除路径分隔符和控制字符
String cleaned = name.replaceAll("[/\\\\:*?\"<>|\\x00-\\x1F]", "-");
// 第二步保留 Unicode 字母和数字其他替换为下划线
cleaned = cleaned.replaceAll("[^\\p{L}\\p{N}_\\-.\\s]", "_");
// 第三步折叠连续分隔符
cleaned = cleaned.replaceAll("[_\\s]+", "_").replaceAll("[-_]+", "_");
// 第四步去掉首尾分隔符
cleaned = cleaned.replaceAll("^-|-$", "");
return cleaned.isEmpty() ? "unnamed" : cleaned;
}
/**
@ -116,11 +139,18 @@ public class SkillWorkspaceManager {
Files.createDirectories(workspaceDir.resolve("scripts"));
Path skillMd = workspaceDir.resolve("SKILL.md");
if (overwrite || !Files.exists(skillMd)) {
String content = (initialContent != null && !initialContent.isBlank())
? initialContent
: buildDefaultSkillMd(skillName);
String content = (initialContent != null && !initialContent.isBlank())
? initialContent
: buildDefaultSkillMd(skillName);
if (overwrite) {
Files.writeString(skillMd, content);
} else {
// 原子创建避免并发上传时的 TOCTOU 竞态
try {
Files.writeString(skillMd, content, StandardOpenOption.CREATE_NEW);
} catch (FileAlreadyExistsException e) {
// 另一线程已创建跳过写入
}
}
log.info("Initialized skill workspace: {} (overwrite={})", workspaceDir, overwrite);