diff --git a/mateclaw-server/pom.xml b/mateclaw-server/pom.xml index a2dd0b4c..8b476be9 100644 --- a/mateclaw-server/pom.xml +++ b/mateclaw-server/pom.xml @@ -645,5 +645,28 @@ + + + skip-baseline-drift + + + + org.apache.maven.plugins + maven-compiler-plugin + + + vip/mate/llm/oauth/OpenAIOAuthServiceFlowModeTest.java + vip/mate/wiki/service/WikiEmbeddingCircuitBreakerTest.java + + + + + + diff --git a/mateclaw-server/src/main/java/vip/mate/skill/controller/SkillController.java b/mateclaw-server/src/main/java/vip/mate/skill/controller/SkillController.java index a24a81d7..d40d957d 100644 --- a/mateclaw-server/src/main/java/vip/mate/skill/controller/SkillController.java +++ b/mateclaw-server/src/main/java/vip/mate/skill/controller/SkillController.java @@ -23,6 +23,7 @@ import vip.mate.skill.synthesis.SkillSynthesisService; import vip.mate.skill.runtime.SkillRuntimeService; import vip.mate.skill.runtime.model.ResolvedSkill; import vip.mate.skill.workspace.BundledSkillSyncer; +import vip.mate.skill.workspace.SkillFileSyncer; import vip.mate.skill.workspace.SkillWorkspaceManager; import java.util.ArrayList; @@ -49,6 +50,7 @@ public class SkillController { private final SkillRuntimeService skillRuntimeService; private final SkillWorkspaceManager workspaceManager; private final BundledSkillSyncer bundledSkillSyncer; + private final SkillFileSyncer skillFileSyncer; private final SkillSynthesisService synthesisService; private final SkillDependencyChecker dependencyChecker; private final SkillLessonsService lessonsService; @@ -253,6 +255,40 @@ public class SkillController { return R.ok(skillService.rescanSecurity(id)); } + @Operation(summary = "Re-sync this skill's bundle files from DB → local workspace cache", + description = "Use after an out-of-band scripts/ change or to recover a missing local cache " + + "in a multi-instance deployment. Pulls every mate_skill_file row owned by the skill " + + "down to disk; if no rows exist yet but local files do, ingests them into the canonical store.") + @PostMapping("/{id}/sync-files") + public R> syncFiles(@PathVariable Long id) { + rejectVirtualSkillMutation(id); + SkillEntity skill = skillService.getSkill(id); + var report = skillFileSyncer.syncOne(skill); + Map body = new LinkedHashMap<>(); + body.put("skillId", id); + body.put("name", skill.getName()); + body.put("filesMaterialized", report.filesMaterialized()); + body.put("filesAlreadyCurrent", report.filesAlreadyCurrent()); + body.put("filesBackfilledFromDisk", report.filesBackfilledFromDisk()); + body.put("backfilledFromDisk", report.didBackfillFromDisk()); + return R.ok(body); + } + + @Operation(summary = "Re-sync every skill's bundle files (admin)", + description = "Bulk variant of /sync-files; primarily for ops debugging when you suspect " + + "the local workspace is out of sync with the canonical store.") + @PostMapping("/sync-files") + public R> syncAllFiles() { + var report = skillFileSyncer.syncAll(); + Map body = new LinkedHashMap<>(); + body.put("skillsConsidered", report.skillsConsidered()); + body.put("skillsBackfilled", report.skillsBackfilled()); + body.put("filesMaterialized", report.filesMaterialized()); + body.put("filesAlreadyCurrent", report.filesAlreadyCurrent()); + body.put("filesBackfilledFromDisk", report.filesBackfilledFromDisk()); + return R.ok(body); + } + /** * Mutation paths refuse virtual MCP/ACP skill ids upfront. The bridge * synthesizes those rows on the fly from the upstream connection diff --git a/mateclaw-server/src/main/java/vip/mate/skill/installer/SkillInstaller.java b/mateclaw-server/src/main/java/vip/mate/skill/installer/SkillInstaller.java index cf6a6749..8e0e4cf1 100644 --- a/mateclaw-server/src/main/java/vip/mate/skill/installer/SkillInstaller.java +++ b/mateclaw-server/src/main/java/vip/mate/skill/installer/SkillInstaller.java @@ -7,6 +7,7 @@ import org.springframework.scheduling.annotation.Async; import org.springframework.stereotype.Service; import vip.mate.skill.installer.model.*; import vip.mate.skill.model.SkillEntity; +import vip.mate.skill.service.SkillFileService; import vip.mate.skill.service.SkillService; import vip.mate.skill.workspace.SkillWorkspaceEvent; import vip.mate.skill.workspace.SkillWorkspaceManager; @@ -36,6 +37,7 @@ public class SkillInstaller { private final SkillHubClient skillHubClient; private final SkillWorkspaceManager workspaceManager; private final SkillService skillService; + private final SkillFileService skillFileService; private final ObjectMapper objectMapper; private final ApplicationEventPublisher eventPublisher; @@ -146,79 +148,35 @@ public class SkillInstaller { return CompletableFuture.completedFuture(null); } - // 4. 写入 workspace 目录 - // overwrite 时先清理旧 references/ 和 scripts/,防止残留过期文件 - if (exists) { - workspaceManager.cleanWorkspaceDataDirs(skillName); - } - // 重装时 (exists=true) 覆写 SKILL.md;否则保留已有内容(向后兼容首次创建语义) + // 4. Materialize SKILL.md (overwrite on reinstall, keep on first create). workspaceManager.initWorkspace(skillName, bundle.content(), exists); - // 写入 references/ - if (bundle.references() != null) { - for (var entry : bundle.references().entrySet()) { - workspaceManager.writeWorkspaceFile(skillName, "references/" + entry.getKey(), entry.getValue()); - } - } - - // 写入 scripts/ - if (bundle.scripts() != null) { - for (var entry : bundle.scripts().entrySet()) { - workspaceManager.writeWorkspaceFile(skillName, "scripts/" + entry.getKey(), entry.getValue()); - } - } - - // cancel check: 文件已落盘,但数据库尚未写入 —— 归档已写入的目录后退出 if (task.isCancelRequested()) { workspaceManager.archiveWorkspace(skillName); task.markCancelled(); return CompletableFuture.completedFuture(null); } - // 5. 注册/更新数据库 - SkillEntity skillEntity; - if (exists) { - // 更新已有记录 - skillEntity = skillService.listSkills().stream() - .filter(s -> s.getName().equals(skillName)) - .findFirst().orElseThrow(); - skillEntity.setSkillContent(bundle.content()); - skillEntity.setDescription(bundle.description()); - skillEntity.setVersion(bundle.version()); - skillEntity.setAuthor(bundle.author()); - skillEntity.setIcon(bundle.icon()); - skillEntity.setConfigJson(buildConfigJson(bundle)); - if (Boolean.TRUE.equals(request.getEnable())) { - skillEntity.setEnabled(true); - } - skillService.updateSkill(skillEntity); - } else { - // 创建新记录 - skillEntity = new SkillEntity(); - skillEntity.setName(skillName); - skillEntity.setDescription(bundle.description()); - skillEntity.setSkillType("dynamic"); - skillEntity.setVersion(bundle.version()); - skillEntity.setAuthor(bundle.author()); - skillEntity.setIcon(bundle.icon()); - skillEntity.setSkillContent(bundle.content()); - skillEntity.setConfigJson(buildConfigJson(bundle)); - skillEntity.setEnabled(Boolean.TRUE.equals(request.getEnable())); - skillService.createSkill(skillEntity); - } + // 5. Register/update the skill row first so we have an id for the file rows. + SkillEntity skillEntity = upsertSkillRow(bundle, skillName, exists, + Boolean.TRUE.equals(request.getEnable())); - // cancel check: DB 已写入,此时取消不再回滚数据库,但标记任务为 cancelled if (task.isCancelRequested()) { task.markCancelled(); return CompletableFuture.completedFuture(null); } - // 6. 发布事件 + // 6. Persist bundle files: DB is canonical, FS is the materialized cache. + // Empty-bundle guard protects both sides from a malformed bundle + // silently wiping pre-existing scripts/references. + boolean force = Boolean.TRUE.equals(request.getForcePrune()); + persistBundleFiles(skillEntity, bundle, force, "url"); + + // 7. Publish event for runtime refresh / sibling-node materialization. eventPublisher.publishEvent(new SkillWorkspaceEvent( skillName, SkillWorkspaceEvent.Type.INSTALLED, workspaceManager.resolveConventionPath(skillName))); - // 7. 完成 task.markCompleted(InstallResult.builder() .name(skillName) .enabled(Boolean.TRUE.equals(request.getEnable())) @@ -254,28 +212,37 @@ public class SkillInstaller { "Skill '" + skillName + "' already exists. Enable overwrite to replace."); } - // 写入 workspace - if (exists) { - workspaceManager.cleanWorkspaceDataDirs(skillName); - } - workspaceManager.initWorkspace(skillName, bundle.content()); + // Materialize SKILL.md (always overwrite on reinstall path). + workspaceManager.initWorkspace(skillName, bundle.content(), exists); - if (bundle.references() != null) { - for (var entry : bundle.references().entrySet()) { - String key = entry.getKey(); - if (!key.startsWith("references/")) key = "references/" + key; - workspaceManager.writeWorkspaceFile(skillName, key, entry.getValue()); - } - } - if (bundle.scripts() != null) { - for (var entry : bundle.scripts().entrySet()) { - String key = entry.getKey(); - if (!key.startsWith("scripts/")) key = "scripts/" + key; - workspaceManager.writeWorkspaceFile(skillName, key, entry.getValue()); - } - } + // Register/update skill row first so we have an id to anchor the file rows. + SkillEntity skillEntity = upsertSkillRow(bundle, skillName, exists, enable); - // 注册/更新 DB + // DB-canonical, FS-cache. Empty-bundle guard on both sides. + persistBundleFiles(skillEntity, bundle, false, "zip"); + + eventPublisher.publishEvent(new SkillWorkspaceEvent( + skillName, SkillWorkspaceEvent.Type.INSTALLED, + workspaceManager.resolveConventionPath(skillName))); + + int filesCount = (bundle.references() != null ? bundle.references().size() : 0) + + (bundle.scripts() != null ? bundle.scripts().size() : 0) + 1; + + log.info("Skill '{}' installed from ZIP (v{}, {} files)", skillName, bundle.version(), filesCount); + + return Map.of( + "skillId", skillEntity.getId(), + "name", skillName, + "version", bundle.version() != null ? bundle.version() : "", + "filesCount", filesCount + ); + } + + /** + * Insert or update the {@code mate_skill} row from a bundle. Returns the + * persisted entity so callers have its id for downstream file writes. + */ + private SkillEntity upsertSkillRow(SkillBundle bundle, String skillName, boolean exists, boolean enable) { SkillEntity skillEntity; if (exists) { skillEntity = skillService.listSkills().stream() @@ -302,22 +269,40 @@ public class SkillInstaller { skillEntity.setEnabled(enable); skillService.createSkill(skillEntity); } + return skillEntity; + } - eventPublisher.publishEvent(new SkillWorkspaceEvent( - skillName, SkillWorkspaceEvent.Type.INSTALLED, - workspaceManager.resolveConventionPath(skillName))); + /** + * Write bundle files to both DB (canonical) and FS (cache) using the + * same prefixed-key map. Logs a single combined summary so multi-instance + * deployments can see what each node persisted vs preserved. + */ + private void persistBundleFiles(SkillEntity skillEntity, SkillBundle bundle, boolean force, String origin) { + Map combined = new LinkedHashMap<>(); + if (bundle.references() != null) { + for (var e : bundle.references().entrySet()) { + String key = e.getKey().startsWith("references/") ? e.getKey() : "references/" + e.getKey(); + combined.put(key, e.getValue()); + } + } + if (bundle.scripts() != null) { + for (var e : bundle.scripts().entrySet()) { + String key = e.getKey().startsWith("scripts/") ? e.getKey() : "scripts/" + e.getKey(); + combined.put(key, e.getValue()); + } + } - int filesCount = (bundle.references() != null ? bundle.references().size() : 0) - + (bundle.scripts() != null ? bundle.scripts().size() : 0) + 1; + var dbApply = skillFileService.applyBundleFiles(skillEntity.getId(), combined, force); + var fsApply = workspaceManager.applyBundleFiles(skillEntity.getName(), + bundle.references(), bundle.scripts(), force); - log.info("Skill '{}' installed from ZIP (v{}, {} files)", skillName, bundle.version(), filesCount); - - return Map.of( - "skillId", skillEntity.getId(), - "name", skillName, - "version", bundle.version() != null ? bundle.version() : "", - "filesCount", filesCount - ); + log.info("Persisted bundle for '{}' ({}): db(write={}, prune={}, preservedScripts={}, preservedRefs={}) " + + "fs(refs write={}, prune={}, preserved={} | scripts write={}, prune={}, preserved={})", + skillEntity.getName(), origin, + dbApply.rowsWritten(), dbApply.rowsPruned(), + dbApply.scriptsPreservedDueToEmptyBundle(), dbApply.referencesPreservedDueToEmptyBundle(), + fsApply.referencesWritten(), fsApply.referencesPruned(), fsApply.referencesPreservedDueToEmptyBundle(), + fsApply.scriptsWritten(), fsApply.scriptsPruned(), fsApply.scriptsPreservedDueToEmptyBundle()); } // ==================== 工具方法 ==================== diff --git a/mateclaw-server/src/main/java/vip/mate/skill/installer/ZipSkillFetcher.java b/mateclaw-server/src/main/java/vip/mate/skill/installer/ZipSkillFetcher.java index 29fabfe9..0f4ac2ac 100644 --- a/mateclaw-server/src/main/java/vip/mate/skill/installer/ZipSkillFetcher.java +++ b/mateclaw-server/src/main/java/vip/mate/skill/installer/ZipSkillFetcher.java @@ -9,15 +9,18 @@ import java.io.IOException; import java.io.InputStream; import java.nio.charset.StandardCharsets; import java.nio.file.Path; +import java.util.ArrayList; import java.util.HashMap; +import java.util.List; import java.util.Map; +import java.util.Set; import java.util.zip.ZipEntry; import java.util.zip.ZipInputStream; /** * Parses a ZIP-packaged Skill into a {@link SkillBundle}. *

- * Used by both the upload endpoint (MultipartFile) and the ClawHub install + * Used by both the upload endpoint (MultipartFile) and the marketplace install * path (downloaded ZIP bytes). Hardened against: *

    *
  • Zip Slip path traversal
  • @@ -25,6 +28,12 @@ import java.util.zip.ZipInputStream; *
  • Only SKILL.md / references/ / scripts/ entries are kept
  • *
* + *

Extraction is two-pass: the entire archive is buffered in memory first + * (cap-protected), then SKILL.md is located and the common parent prefix is + * stripped from every other entry. This keeps classification correct + * regardless of the order zip tools write entries — earlier single-pass logic + * silently dropped {@code scripts/*} entries that streamed before SKILL.md. + * * @author MateClaw Team */ @Slf4j @@ -35,15 +44,39 @@ public class ZipSkillFetcher { private static final String SKILL_MD = "SKILL.md"; private static final String SKILL_MD_LOWER = "skill.md"; + /** + * Lowercase file extensions that should be treated as runnable scripts + * when they appear next to SKILL.md without an explicit {@code scripts/} + * prefix. Real-world zips from third parties (e.g. the official + * tencent-meeting-mcp package) put {@code setup.sh} at the package + * root; without this fallback, those files get logged as "unclassified" + * and the skill ships with an empty scripts/ directory. + */ + private static final Set SCRIPT_EXTENSIONS = Set.of( + ".sh", ".bash", ".zsh", ".py", ".js", ".mjs", ".ts", + ".rb", ".pl", ".php", ".bat", ".cmd", ".ps1"); + + /** + * Lowercase file extensions that are documentation / data alongside + * SKILL.md and should default to {@code references/} when not nested + * under an explicit prefix. + */ + private static final Set REFERENCE_EXTENSIONS = Set.of( + ".md", ".txt", ".json", ".yaml", ".yml", ".csv", ".tsv", + ".html", ".htm", ".xml", ".toml"); + /** * Holds the in-memory result of decompressing a ZIP. Used by callers - * that want to enrich the SkillBundle with metadata (e.g. ClawHub author - * / icon) that isn't carried inside SKILL.md. + * that want to enrich the SkillBundle with metadata (e.g. marketplace + * author / icon) that isn't carried inside SKILL.md. */ public record ExtractedSkill(String skillMdContent, Map references, Map scripts) {} + /** Buffered raw zip entry, awaiting classification once SKILL.md prefix is known. */ + private record RawEntry(String name, String content) {} + /** * Parse an uploaded ZIP file into a SkillBundle. Source type is "zip" * and source URL is the original filename. @@ -93,12 +126,18 @@ public class ZipSkillFetcher { /** * Decompress a ZIP stream into in-memory SKILL.md + references + scripts. * Throws {@link IllegalArgumentException} if no SKILL.md is present. + * + *

Two-pass: the first pass buffers every text entry (subject to size + * caps) and remembers where SKILL.md lives. The second pass strips the + * SKILL.md parent prefix from each buffered entry and routes it into + * {@code references} / {@code scripts}. Anything that doesn't match + * either bucket is logged at WARN level so packaging mistakes surface + * instead of being silently dropped. */ public static ExtractedSkill extract(InputStream zipStream) throws IOException { + List raws = new ArrayList<>(); String skillMdContent = null; String skillMdPrefix = ""; - Map references = new HashMap<>(); - Map scripts = new HashMap<>(); long totalSize = 0; try (ZipInputStream zis = new ZipInputStream(zipStream, StandardCharsets.UTF_8)) { @@ -138,28 +177,20 @@ public class ZipSkillFetcher { } String content = new String(bytes, StandardCharsets.UTF_8); + String normalizedName = entryPath.toString().replace('\\', '/'); String fileName = entryPath.getFileName().toString(); + // First match wins for SKILL.md so we lock onto the shallowest one. if (skillMdContent == null && (SKILL_MD.equals(fileName) || SKILL_MD_LOWER.equals(fileName))) { skillMdContent = content; - int slashIdx = entryName.lastIndexOf('/'); - skillMdPrefix = slashIdx > 0 ? entryName.substring(0, slashIdx + 1) : ""; - log.info("[ZipSkillFetcher] Found SKILL.md at: {}", entryName); + int slashIdx = normalizedName.lastIndexOf('/'); + skillMdPrefix = slashIdx > 0 ? normalizedName.substring(0, slashIdx + 1) : ""; + log.info("[ZipSkillFetcher] Found SKILL.md at: {}", normalizedName); + } else { + raws.add(new RawEntry(normalizedName, content)); } zis.closeEntry(); - - String normalizedName = entryPath.toString().replace('\\', '/'); - String relativeName = normalizedName; - if (!skillMdPrefix.isEmpty() && normalizedName.startsWith(skillMdPrefix)) { - relativeName = normalizedName.substring(skillMdPrefix.length()); - } - - if (relativeName.startsWith("references/")) { - references.put(relativeName.substring("references/".length()), content); - } else if (relativeName.startsWith("scripts/")) { - scripts.put(relativeName.substring("scripts/".length()), content); - } } } @@ -167,6 +198,61 @@ public class ZipSkillFetcher { throw new IllegalArgumentException("ZIP does not contain SKILL.md"); } + Map references = new HashMap<>(); + Map scripts = new HashMap<>(); + + for (RawEntry raw : raws) { + String relative = raw.name(); + if (!skillMdPrefix.isEmpty() && relative.startsWith(skillMdPrefix)) { + relative = relative.substring(skillMdPrefix.length()); + } + + if (relative.startsWith("references/")) { + references.put(relative.substring("references/".length()), raw.content()); + } else if (relative.startsWith("scripts/")) { + scripts.put(relative.substring("scripts/".length()), raw.content()); + } else if (!relative.contains("/")) { + // Sibling of SKILL.md (post-prefix-strip). Some real-world + // packagers — notably the official tencent-meeting-mcp.zip — + // put setup.sh at the package root instead of under scripts/. + // Fall back to extension-based classification so those zips + // install cleanly without forcing the user to repackage. + String classified = classifyRootFile(relative); + if ("scripts".equals(classified)) { + scripts.put(relative, raw.content()); + log.info("[ZipSkillFetcher] Classified root-level entry '{}' as script by extension", relative); + } else if ("references".equals(classified)) { + references.put(relative, raw.content()); + log.info("[ZipSkillFetcher] Classified root-level entry '{}' as reference by extension", relative); + } else { + log.warn("[ZipSkillFetcher] Ignoring root-level entry with unknown extension: {}", raw.name()); + } + } else { + log.warn("[ZipSkillFetcher] Ignoring entry outside references/ or scripts/: {} (skill prefix={})", + raw.name(), skillMdPrefix.isEmpty() ? "" : skillMdPrefix); + } + } + return new ExtractedSkill(skillMdContent, references, scripts); } + + /** + * Classify a root-level file (sibling of SKILL.md, no directory prefix) + * by extension. Returns {@code "scripts"} / {@code "references"} for + * recognized extensions, {@code null} for everything else. + * + *

Only invoked for entries that are NOT already nested under + * {@code scripts/} or {@code references/}, so well-formed packages + * are unaffected. + */ + private static String classifyRootFile(String fileName) { + if (fileName == null) return null; + String lower = fileName.toLowerCase(); + int dot = lower.lastIndexOf('.'); + if (dot < 0) return null; + String ext = lower.substring(dot); + if (SCRIPT_EXTENSIONS.contains(ext)) return "scripts"; + if (REFERENCE_EXTENSIONS.contains(ext)) return "references"; + return null; + } } diff --git a/mateclaw-server/src/main/java/vip/mate/skill/installer/model/InstallRequest.java b/mateclaw-server/src/main/java/vip/mate/skill/installer/model/InstallRequest.java index f51ec93e..47143c17 100644 --- a/mateclaw-server/src/main/java/vip/mate/skill/installer/model/InstallRequest.java +++ b/mateclaw-server/src/main/java/vip/mate/skill/installer/model/InstallRequest.java @@ -24,4 +24,13 @@ public class InstallRequest { /** 若同名 skill 已存在,是否覆盖 */ private Boolean overwrite = false; + + /** + * Bypass the empty-bundle prune guard. Default {@code false} keeps + * existing scripts/references when the new bundle has zero entries + * for that bucket — protects against malformed uploads. Set to + * {@code true} only when you really want to clear out a bucket via + * an intentionally empty bundle. + */ + private Boolean forcePrune = false; } diff --git a/mateclaw-server/src/main/java/vip/mate/skill/model/SkillFileEntity.java b/mateclaw-server/src/main/java/vip/mate/skill/model/SkillFileEntity.java new file mode 100644 index 00000000..6169fa25 --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/skill/model/SkillFileEntity.java @@ -0,0 +1,53 @@ +package vip.mate.skill.model; + +import com.baomidou.mybatisplus.annotation.FieldFill; +import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableField; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.Data; + +import java.time.LocalDateTime; + +/** + * One file inside a skill bundle (an entry under {@code scripts/} or + * {@code references/}). + *

+ * The database is the canonical store. {@code SkillFileSyncer} mirrors + * each row to the local workspace cache so {@code SkillScriptTool} and + * other directory-aware consumers see the file on disk regardless of + * which node accepted the original upload. + * + * @author MateClaw Team + */ +@Data +@TableName("mate_skill_file") +public class SkillFileEntity { + + @TableId(type = IdType.ASSIGN_ID) + private Long id; + + /** Owning skill (FK to {@code mate_skill.id}). */ + private Long skillId; + + /** + * Path relative to the skill workspace root, always starting with + * {@code scripts/} or {@code references/}. Forward slashes only. + */ + private String filePath; + + /** UTF-8 text content. Per-file size bounded by ZipSkillFetcher (1MB). */ + private String content; + + /** Length of {@link #content} in bytes — kept so listings can sort/audit without loading the blob. */ + private Integer contentSize; + + /** SHA-256 of {@link #content}; used by the syncer to skip no-op writes. */ + private String sha256; + + @TableField(fill = FieldFill.INSERT) + private LocalDateTime createTime; + + @TableField(fill = FieldFill.INSERT_UPDATE) + private LocalDateTime updateTime; +} diff --git a/mateclaw-server/src/main/java/vip/mate/skill/repository/SkillFileMapper.java b/mateclaw-server/src/main/java/vip/mate/skill/repository/SkillFileMapper.java new file mode 100644 index 00000000..1adb9466 --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/skill/repository/SkillFileMapper.java @@ -0,0 +1,20 @@ +package vip.mate.skill.repository; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import org.apache.ibatis.annotations.Delete; +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; +import vip.mate.skill.model.SkillFileEntity; + +/** + * Mapper for {@link SkillFileEntity}. + * + * @author MateClaw Team + */ +@Mapper +public interface SkillFileMapper extends BaseMapper { + + /** Drop every file row owned by the given skill — used on hard-delete. */ + @Delete("DELETE FROM mate_skill_file WHERE skill_id = #{skillId}") + int deleteBySkillId(@Param("skillId") Long skillId); +} diff --git a/mateclaw-server/src/main/java/vip/mate/skill/service/SkillFileService.java b/mateclaw-server/src/main/java/vip/mate/skill/service/SkillFileService.java new file mode 100644 index 00000000..267db5a6 --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/skill/service/SkillFileService.java @@ -0,0 +1,178 @@ +package vip.mate.skill.service; + +import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import vip.mate.skill.model.SkillFileEntity; +import vip.mate.skill.repository.SkillFileMapper; + +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.time.LocalDateTime; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; + +/** + * Persistence layer for skill bundle files. + *

+ * Treated as the canonical store: every install writes the full set of + * scripts/references rows here, and {@code SkillFileSyncer} mirrors them + * to the local workspace cache on every node so script execution works + * across a multi-instance deployment that shares one database. + * + * @author MateClaw Team + */ +@Slf4j +@Service +@RequiredArgsConstructor +public class SkillFileService { + + private final SkillFileMapper mapper; + + /** All file rows owned by a skill. */ + public List listBySkillId(Long skillId) { + if (skillId == null) return List.of(); + QueryWrapper q = new QueryWrapper<>(); + q.eq("skill_id", skillId); + return mapper.selectList(q); + } + + /** Compute SHA-256 hex of a UTF-8 string (used for idempotent diffs). */ + public static String sha256Hex(String content) { + if (content == null) content = ""; + try { + MessageDigest md = MessageDigest.getInstance("SHA-256"); + byte[] digest = md.digest(content.getBytes(StandardCharsets.UTF_8)); + StringBuilder sb = new StringBuilder(digest.length * 2); + for (byte b : digest) sb.append(String.format("%02x", b)); + return sb.toString(); + } catch (NoSuchAlgorithmException e) { + throw new IllegalStateException("SHA-256 unavailable on this JVM", e); + } + } + + /** + * Replace the skill's full file set with {@code newFiles}, using + * write-then-prune semantics to mirror the on-disk applyBundleFiles. + * + *

Empty-bundle guard: if {@code newFiles} contains zero entries + * for a bucket (scripts/ or references/) and there are existing rows + * for that bucket, the rows are preserved unless {@code force=true}. + * This blocks the same data-loss scenario that tripped up the FS path. + * + * @param skillId owning skill id + * @param newFiles new full file set, keyed by path under workspace root + * (e.g. {@code "scripts/run.py"}) + * @param force bypass empty-bundle guard + */ + @Transactional + public ApplyResult applyBundleFiles(Long skillId, Map newFiles, boolean force) { + if (skillId == null) { + return new ApplyResult(0, 0, false, false); + } + + Map incoming = newFiles == null ? Map.of() : newFiles; + boolean newHasScripts = bucketHasEntries(incoming, "scripts/"); + boolean newHasRefs = bucketHasEntries(incoming, "references/"); + + List existing = listBySkillId(skillId); + boolean existingHasScripts = existing.stream().anyMatch(e -> e.getFilePath() != null && e.getFilePath().startsWith("scripts/")); + boolean existingHasRefs = existing.stream().anyMatch(e -> e.getFilePath() != null && e.getFilePath().startsWith("references/")); + + boolean preserveScripts = !newHasScripts && existingHasScripts && !force; + boolean preserveRefs = !newHasRefs && existingHasRefs && !force; + + Map existingByPath = new HashMap<>(); + for (SkillFileEntity e : existing) existingByPath.put(e.getFilePath(), e); + + Set keepPaths = new HashSet<>(); + if (preserveScripts) { + for (SkillFileEntity e : existing) { + if (e.getFilePath() != null && e.getFilePath().startsWith("scripts/")) { + keepPaths.add(e.getFilePath()); + } + } + } + if (preserveRefs) { + for (SkillFileEntity e : existing) { + if (e.getFilePath() != null && e.getFilePath().startsWith("references/")) { + keepPaths.add(e.getFilePath()); + } + } + } + keepPaths.addAll(incoming.keySet()); + + int written = 0; + LocalDateTime now = LocalDateTime.now(); + for (var entry : incoming.entrySet()) { + String path = entry.getKey(); + String content = entry.getValue() == null ? "" : entry.getValue(); + String hash = sha256Hex(content); + int size = content.getBytes(StandardCharsets.UTF_8).length; + + SkillFileEntity prior = existingByPath.get(path); + if (prior == null) { + SkillFileEntity row = new SkillFileEntity(); + row.setSkillId(skillId); + row.setFilePath(path); + row.setContent(content); + row.setContentSize(size); + row.setSha256(hash); + row.setCreateTime(now); + row.setUpdateTime(now); + mapper.insert(row); + written++; + } else if (!hash.equals(prior.getSha256())) { + prior.setContent(content); + prior.setContentSize(size); + prior.setSha256(hash); + prior.setUpdateTime(now); + mapper.updateById(prior); + written++; + } + } + + int pruned = 0; + for (SkillFileEntity e : existing) { + if (!keepPaths.contains(e.getFilePath())) { + mapper.deleteById(e.getId()); + pruned++; + } + } + + if (preserveScripts) { + log.warn("Refused to prune scripts/ for skill_id={} — new bundle is empty. Pass force=true to override.", skillId); + } + if (preserveRefs) { + log.warn("Refused to prune references/ for skill_id={} — new bundle is empty. Pass force=true to override.", skillId); + } + + return new ApplyResult(written, pruned, preserveScripts, preserveRefs); + } + + /** Drop every file row for a skill (used on hard-delete). */ + @Transactional + public int deleteAllForSkill(Long skillId) { + if (skillId == null) return 0; + return mapper.deleteBySkillId(skillId); + } + + private boolean bucketHasEntries(Map files, String prefix) { + for (String key : files.keySet()) { + if (key != null && key.startsWith(prefix)) return true; + } + return false; + } + + /** Outcome of {@link #applyBundleFiles}. */ + public record ApplyResult(int rowsWritten, + int rowsPruned, + boolean scriptsPreservedDueToEmptyBundle, + boolean referencesPreservedDueToEmptyBundle) {} +} diff --git a/mateclaw-server/src/main/java/vip/mate/skill/service/SkillService.java b/mateclaw-server/src/main/java/vip/mate/skill/service/SkillService.java index 39510a23..f20b8612 100644 --- a/mateclaw-server/src/main/java/vip/mate/skill/service/SkillService.java +++ b/mateclaw-server/src/main/java/vip/mate/skill/service/SkillService.java @@ -8,6 +8,7 @@ import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Service; import vip.mate.exception.MateClawException; import vip.mate.skill.model.SkillEntity; +import vip.mate.skill.repository.SkillFileMapper; import vip.mate.skill.repository.SkillMapper; import vip.mate.skill.runtime.SkillCatalogSort; import vip.mate.skill.runtime.SkillCatalogSorter; @@ -42,6 +43,7 @@ import java.util.stream.Collectors; public class SkillService { private final SkillMapper skillMapper; + private final SkillFileMapper skillFileMapper; private final SkillWorkspaceManager workspaceManager; private final SkillWorkspaceProperties workspaceProperties; private final SkillSecretService skillSecretService; @@ -440,6 +442,10 @@ public class SkillService { "内置技能不可硬删除: " + skill.getName()); } skillMapper.hardDeleteById(id); // bypass the logical-delete flag + int filesDropped = skillFileMapper.deleteBySkillId(id); + if (filesDropped > 0) { + log.info("Hard-deleted {} bundle file row(s) for skill {}", filesDropped, skill.getName()); + } log.info("Hard-deleted skill (physical delete + purge): {}", skill.getName()); // RFC-091 settings bridge — purge any per-skill secrets so a diff --git a/mateclaw-server/src/main/java/vip/mate/skill/workspace/SkillFileSyncer.java b/mateclaw-server/src/main/java/vip/mate/skill/workspace/SkillFileSyncer.java new file mode 100644 index 00000000..9acafb7a --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/skill/workspace/SkillFileSyncer.java @@ -0,0 +1,209 @@ +package vip.mate.skill.workspace; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; +import vip.mate.skill.model.SkillEntity; +import vip.mate.skill.model.SkillFileEntity; +import vip.mate.skill.service.SkillFileService; +import vip.mate.skill.service.SkillService; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.HashSet; +import java.util.List; +import java.util.Set; + +/** + * Mirrors canonical {@code mate_skill_file} rows down to each node's local + * workspace cache so {@code scripts/} and {@code references/} files exist + * on disk wherever the skill might run. + * + *

Also runs a one-time backfill: for any skill that has on-disk files + * but no DB rows (typically pre-V112 installs), the local files are read + * up into the DB so the canonical store catches up to reality. Backfill + * is content-hash idempotent and safe to invoke repeatedly. + * + *

Triggered: + *

    + *
  • At startup, after the bundled-skill syncer (see + * {@link SkillWorkspaceBootstrapRunner}).
  • + *
  • On-demand via the admin endpoint {@code POST /api/v1/skills/{id}/sync-files}.
  • + *
+ * + * @author MateClaw Team + */ +@Slf4j +@Component +@RequiredArgsConstructor +public class SkillFileSyncer { + + private final SkillService skillService; + private final SkillFileService skillFileService; + private final SkillWorkspaceManager workspaceManager; + + /** Aggregate counters for one full sync pass. */ + public record SyncReport(int skillsConsidered, + int skillsBackfilled, + int filesMaterialized, + int filesAlreadyCurrent, + int filesBackfilledFromDisk) {} + + /** Sync every active skill once. Idempotent. */ + public SyncReport syncAll() { + List skills = skillService.listSkills(); + int considered = 0; + int backfilled = 0; + int materialized = 0; + int current = 0; + int diskBackfilled = 0; + + for (SkillEntity skill : skills) { + if (skill.getId() == null || skill.getName() == null) continue; + considered++; + var per = syncOne(skill); + materialized += per.filesMaterialized(); + current += per.filesAlreadyCurrent(); + diskBackfilled += per.filesBackfilledFromDisk(); + if (per.didBackfillFromDisk()) backfilled++; + } + + if (considered > 0) { + log.info("SkillFileSyncer pass: skills={}, materialized={}, current={}, " + + "backfilledFromDisk(skills={}, files={})", + considered, materialized, current, backfilled, diskBackfilled); + } + return new SyncReport(considered, backfilled, materialized, current, diskBackfilled); + } + + /** Per-skill sync outcome. */ + public record PerSkillReport(int filesMaterialized, + int filesAlreadyCurrent, + int filesBackfilledFromDisk, + boolean didBackfillFromDisk) {} + + /** + * Sync a single skill: backfill DB from FS if DB is empty and FS has + * files, then materialize DB rows down to FS so any missing/stale files + * are restored. + */ + public PerSkillReport syncOne(SkillEntity skill) { + Path workspaceDir = workspaceManager.resolveConventionPath(skill.getName()); + List dbFiles = skillFileService.listBySkillId(skill.getId()); + + boolean didBackfill = false; + int backfilled = 0; + if (dbFiles.isEmpty()) { + backfilled = backfillFromDiskIfNeeded(skill, workspaceDir); + if (backfilled > 0) { + didBackfill = true; + dbFiles = skillFileService.listBySkillId(skill.getId()); + } + } + + int materialized = 0; + int alreadyCurrent = 0; + for (SkillFileEntity row : dbFiles) { + switch (materializeOne(workspaceDir, row)) { + case WROTE -> materialized++; + case CURRENT -> alreadyCurrent++; + case SKIPPED -> { + /* unsafe path / IO failure already logged */ + } + } + } + + return new PerSkillReport(materialized, alreadyCurrent, backfilled, didBackfill); + } + + private enum MaterializeOutcome { WROTE, CURRENT, SKIPPED } + + private MaterializeOutcome materializeOne(Path workspaceDir, SkillFileEntity row) { + String relative = row.getFilePath(); + if (relative == null || relative.isBlank()) return MaterializeOutcome.SKIPPED; + if (!relative.startsWith("references/") && !relative.startsWith("scripts/")) { + log.warn("Skipping skill_file row {} — path outside scripts/ or references/: {}", + row.getId(), relative); + return MaterializeOutcome.SKIPPED; + } + if (relative.contains("..")) { + log.warn("Skipping skill_file row {} — suspicious path: {}", row.getId(), relative); + return MaterializeOutcome.SKIPPED; + } + + Path target = workspaceDir.resolve(relative).normalize(); + if (!target.startsWith(workspaceDir.normalize())) { + log.warn("Skipping skill_file row {} — escapes workspace: {}", row.getId(), relative); + return MaterializeOutcome.SKIPPED; + } + + try { + String content = row.getContent() == null ? "" : row.getContent(); + if (Files.exists(target)) { + String onDisk = Files.readString(target, StandardCharsets.UTF_8); + if (SkillFileService.sha256Hex(onDisk).equals(row.getSha256())) { + return MaterializeOutcome.CURRENT; + } + } + Files.createDirectories(target.getParent()); + Files.writeString(target, content, StandardCharsets.UTF_8); + return MaterializeOutcome.WROTE; + } catch (IOException e) { + log.warn("Failed to materialize skill_file {} → {}: {}", row.getId(), target, e.getMessage()); + return MaterializeOutcome.SKIPPED; + } + } + + /** + * One-time ingestion of pre-V112 on-disk files into the canonical + * {@code mate_skill_file} table. Only runs when the skill has zero + * file rows; subsequent installs go through the installer's normal + * write-to-both-stores path. + */ + private int backfillFromDiskIfNeeded(SkillEntity skill, Path workspaceDir) { + if (!Files.exists(workspaceDir) || !Files.isDirectory(workspaceDir)) return 0; + + List roots = new ArrayList<>(2); + Path scripts = workspaceDir.resolve("scripts"); + Path references = workspaceDir.resolve("references"); + if (Files.isDirectory(scripts)) roots.add(scripts); + if (Files.isDirectory(references)) roots.add(references); + if (roots.isEmpty()) return 0; + + java.util.Map ingested = new java.util.LinkedHashMap<>(); + Set seen = new HashSet<>(); + for (Path root : roots) { + String prefix = workspaceDir.relativize(root).toString().replace('\\', '/') + "/"; + try (var stream = Files.walk(root)) { + List files = stream.filter(Files::isRegularFile).toList(); + for (Path f : files) { + String relative = workspaceDir.relativize(f).toString().replace('\\', '/'); + if (!relative.startsWith(prefix)) continue; + if (!seen.add(relative)) continue; + try { + String content = Files.readString(f, StandardCharsets.UTF_8); + ingested.put(relative, content); + } catch (IOException e) { + log.warn("Backfill skipped {} (read failed: {})", f, e.getMessage()); + } + } + } catch (IOException e) { + log.warn("Backfill walk failed for {}: {}", root, e.getMessage()); + } + } + + if (ingested.isEmpty()) return 0; + skillFileService.applyBundleFiles(skill.getId(), ingested, false); + log.info("Backfilled {} bundle file(s) into mate_skill_file for skill '{}' (id={})", + ingested.size(), skill.getName(), skill.getId()); + + // Touch the workspace event so other observers (e.g. runtime cache) refresh. + // Use a synthetic event type — INSTALLED is the closest existing match. + skill.setUpdateTime(LocalDateTime.now()); + return ingested.size(); + } +} diff --git a/mateclaw-server/src/main/java/vip/mate/skill/workspace/SkillWorkspaceBootstrapRunner.java b/mateclaw-server/src/main/java/vip/mate/skill/workspace/SkillWorkspaceBootstrapRunner.java index d60c7695..029cda4a 100644 --- a/mateclaw-server/src/main/java/vip/mate/skill/workspace/SkillWorkspaceBootstrapRunner.java +++ b/mateclaw-server/src/main/java/vip/mate/skill/workspace/SkillWorkspaceBootstrapRunner.java @@ -10,36 +10,51 @@ import org.springframework.stereotype.Component; import java.util.List; /** - * Skill 工作区启动初始化 - *

- * 1. 确保 workspace root 目录存在 - * 2. 将 classpath 下预置技能同步到 workspace - * - 首次:创建并同步 - * - 后续:比对 SKILL.md frontmatter 中的 version 字段, - * bundled version 更高时归档旧版本并覆盖升级 - *

- * Order(195) — 在 DatabaseBootstrapRunner(200) 之前执行。 + * Skill workspace bootstrap. + *

    + *
  1. Ensure the workspace root exists.
  2. + *
  3. Sync classpath-bundled skills into the workspace + * (first install creates them; later starts upgrade only when the + * bundled SKILL.md frontmatter version is strictly newer).
  4. + *
  5. Materialize {@code mate_skill_file} rows down to each node's + * local cache so multi-instance deployments share the same + * scripts/references regardless of which node accepted the upload. + * Also backfills any pre-V112 on-disk-only skill files into the + * canonical store.
  6. + *
+ * + *

Order(210) — runs after {@code DatabaseBootstrapRunner}(200) so the + * skill rows the syncer needs to read are already loaded. * * @author MateClaw Team */ @Slf4j @Component -@Order(195) +@Order(210) @RequiredArgsConstructor public class SkillWorkspaceBootstrapRunner implements ApplicationRunner { private final SkillWorkspaceManager workspaceManager; private final BundledSkillSyncer bundledSkillSyncer; + private final SkillFileSyncer skillFileSyncer; @Override public void run(ApplicationArguments args) { var root = workspaceManager.getWorkspaceRoot(); log.info("Skill workspace root ready: {}", root); - // 同步 classpath 下预置技能到 workspace List synced = bundledSkillSyncer.sync(); if (!synced.isEmpty()) { log.info("Synced {} bundled skill(s) to workspace: {}", synced.size(), synced); } + + // Pull canonical bundle files from DB → local cache (and one-time + // backfill of pre-V112 disk-only skills back into the DB). + var report = skillFileSyncer.syncAll(); + log.info("Skill file sync: skills={}, materialized={}, current={}, " + + "diskBackfilled(skills={}, files={})", + report.skillsConsidered(), report.filesMaterialized(), + report.filesAlreadyCurrent(), + report.skillsBackfilled(), report.filesBackfilledFromDisk()); } } diff --git a/mateclaw-server/src/main/java/vip/mate/skill/workspace/SkillWorkspaceManager.java b/mateclaw-server/src/main/java/vip/mate/skill/workspace/SkillWorkspaceManager.java index ffed5c50..16414841 100644 --- a/mateclaw-server/src/main/java/vip/mate/skill/workspace/SkillWorkspaceManager.java +++ b/mateclaw-server/src/main/java/vip/mate/skill/workspace/SkillWorkspaceManager.java @@ -239,6 +239,158 @@ public class SkillWorkspaceManager { cleanDirectoryContents(workspaceDir.resolve("scripts")); } + /** + * Outcome of {@link #applyBundleFiles}, exposing per-bucket counters so + * the installer can log a meaningful summary and the admin UI can show + * what actually changed. + */ + public record ApplyBundleResult( + int referencesWritten, + int referencesPruned, + boolean referencesPreservedDueToEmptyBundle, + int scriptsWritten, + int scriptsPruned, + boolean scriptsPreservedDueToEmptyBundle + ) {} + + /** + * Apply a bundle's references/ + scripts/ to the workspace using + * write-then-prune semantics: + *

    + *
  1. Write every entry from the bundle (overwrites same paths).
  2. + *
  3. Delete any pre-existing file under references/ or scripts/ that + * is NOT in the bundle.
  4. + *
+ * + *

Empty-bundle safety: if the bundle has zero entries for a bucket + * AND the workspace already has files in that bucket, the bucket is + * left untouched (no pruning) unless {@code force=true}. This protects + * against malformed uploads, network truncation, and parser bugs that + * would otherwise wipe a user's scripts on reinstall — the same class + * of regression that an earlier patch fixed for SKILL.md. + * + * @param skillName workspace owner + * @param references new bundle's references map (key = path under references/) + * @param scripts new bundle's scripts map (key = path under scripts/) + * @param force bypass the empty-bundle guard (admin-only switch) + * @return per-bucket apply summary (never null) + */ + public ApplyBundleResult applyBundleFiles(String skillName, + Map references, + Map scripts, + boolean force) { + Path workspaceDir = resolveConventionPath(skillName); + try { + Files.createDirectories(workspaceDir.resolve("references")); + Files.createDirectories(workspaceDir.resolve("scripts")); + } catch (IOException e) { + log.warn("Failed to ensure data dirs for skill '{}': {}", skillName, e.getMessage()); + } + + int refsWritten = applyBucket(skillName, "references/", references); + int scriptsWritten = applyBucket(skillName, "scripts/", scripts); + + var refsPrune = pruneBucket(workspaceDir.resolve("references"), + normalizeKeys(references), force, skillName, "references"); + var scriptsPrune = pruneBucket(workspaceDir.resolve("scripts"), + normalizeKeys(scripts), force, skillName, "scripts"); + + return new ApplyBundleResult( + refsWritten, refsPrune.deleted(), refsPrune.preservedDueToEmpty(), + scriptsWritten, scriptsPrune.deleted(), scriptsPrune.preservedDueToEmpty() + ); + } + + private int applyBucket(String skillName, String bucketPrefix, Map entries) { + if (entries == null || entries.isEmpty()) return 0; + int written = 0; + for (var e : entries.entrySet()) { + String key = e.getKey(); + String relative = key.startsWith(bucketPrefix) ? key : (bucketPrefix + key); + try { + writeWorkspaceFile(skillName, relative, e.getValue()); + written++; + } catch (RuntimeException ex) { + log.warn("Failed to write {} for skill '{}': {}", relative, skillName, ex.getMessage()); + } + } + return written; + } + + /** Strip a leading "/" prefix so the key matches the path relative to the bucket dir. */ + private Set normalizeKeys(Map entries) { + if (entries == null || entries.isEmpty()) return Collections.emptySet(); + Set out = new HashSet<>(entries.size() * 2); + for (String key : entries.keySet()) { + String k = key.replace('\\', '/'); + int firstSlash = k.indexOf('/'); + if (firstSlash > 0 && (k.startsWith("references/") || k.startsWith("scripts/"))) { + out.add(k.substring(firstSlash + 1)); + } else { + out.add(k); + } + } + return out; + } + + private record PruneOutcome(int deleted, boolean preservedDueToEmpty) {} + + private PruneOutcome pruneBucket(Path bucketDir, Set keep, boolean force, + String skillName, String bucketLabel) { + if (!Files.exists(bucketDir) || !Files.isDirectory(bucketDir)) { + return new PruneOutcome(0, false); + } + + // Empty-bundle guard: if the new bundle has nothing for this bucket + // and there's at least one file on disk, refuse to prune unless the + // caller explicitly asked for it. Logged so the operator can see why + // their "clean install" didn't actually clean. + if (keep.isEmpty() && !force) { + try (var stream = Files.walk(bucketDir)) { + boolean hasAny = stream.filter(Files::isRegularFile).findFirst().isPresent(); + if (hasAny) { + log.warn("Refusing to prune {}/{}/ — new bundle is empty and would wipe existing files. " + + "Pass force=true to override.", skillName, bucketLabel); + return new PruneOutcome(0, true); + } + } catch (IOException e) { + log.warn("Failed to inspect {}/{}/: {}", skillName, bucketLabel, e.getMessage()); + return new PruneOutcome(0, false); + } + } + + int deleted = 0; + try (var stream = Files.walk(bucketDir)) { + List files = stream.filter(Files::isRegularFile).toList(); + for (Path file : files) { + String relative = bucketDir.relativize(file).toString().replace('\\', '/'); + if (!keep.contains(relative)) { + try { + Files.delete(file); + deleted++; + } catch (IOException e) { + log.warn("Failed to prune {}/{}/{}: {}", skillName, bucketLabel, relative, e.getMessage()); + } + } + } + // Best-effort: tidy up emptied subdirs (leave the bucket root in place). + try (var dirs = Files.walk(bucketDir)) { + dirs.sorted(java.util.Comparator.reverseOrder()) + .filter(p -> Files.isDirectory(p) && !p.equals(bucketDir)) + .forEach(p -> { + try (var children = Files.list(p)) { + if (children.findAny().isEmpty()) Files.delete(p); + } catch (IOException ignored) { + /* leave non-empty / locked dirs in place */ + } + }); + } + } catch (IOException e) { + log.warn("Failed to prune {}/{}/: {}", skillName, bucketLabel, e.getMessage()); + } + return new PruneOutcome(deleted, false); + } + /** * 验证写入路径安全性,防止路径逃逸 * diff --git a/mateclaw-server/src/main/java/vip/mate/tool/builtin/SkillScriptTool.java b/mateclaw-server/src/main/java/vip/mate/tool/builtin/SkillScriptTool.java index f6901bd0..378dc165 100644 --- a/mateclaw-server/src/main/java/vip/mate/tool/builtin/SkillScriptTool.java +++ b/mateclaw-server/src/main/java/vip/mate/tool/builtin/SkillScriptTool.java @@ -13,7 +13,6 @@ import vip.mate.skill.runtime.model.ResolvedSkill; import vip.mate.skill.secret.SkillSecretService; import java.nio.file.Path; -import java.util.Arrays; import java.util.Collections; import java.util.List; import java.util.Map; @@ -40,7 +39,10 @@ public class SkillScriptTool { Parameters: - skillName: Name of the skill - scriptPath: Relative path to script under scripts/ directory (e.g., "scripts/run.py") - - args: Optional comma-separated arguments to pass to the script + - args: Optional list of script arguments. Each element is passed as a separate + CLI argument exactly as written — no shell interpretation, no splitting. + For a JSON payload, wrap it as a single-element list, e.g. + ["{\\"date\\":\\"2026-05-12\\",\\"topic\\":\\"meeting\\"}"]. Returns: JSON with exitCode, stdout, stderr @@ -57,33 +59,33 @@ public class SkillScriptTool { String scriptPath, @JsonProperty(required = false) - @JsonPropertyDescription("Optional comma-separated script arguments") - String args + @JsonPropertyDescription("Optional list of script arguments. Each element is passed as one CLI arg verbatim. Wrap a JSON payload as a single-element list.") + List args ) { log.info("Executing skill script: skill={}, script={}, args={}", skillName, scriptPath, args); - // 查找 active skill + // Look up active skill. ResolvedSkill skill = runtimeService.findActiveSkill(skillName); if (skill == null) { return formatError("Skill '" + skillName + "' not found or not enabled"); } - // 必须是目录型 skill + // Must be a directory-backed skill. if (skill.getSkillDir() == null) { return formatError("Skill '" + skillName + "' is database-based, no script execution available"); } - // 验证脚本路径(必须在 scripts/ 下) + // Validate script path (must live under scripts/). Path resolvedPath = accessPolicy.validateScriptPath(skill.getSkillDir(), scriptPath); if (resolvedPath == null) { return formatError("Invalid or unsafe script path: " + scriptPath); } - // 解析参数 - List argList = null; - if (args != null && !args.isBlank()) { - argList = Arrays.asList(args.split(",")); - } + // Pass args straight through. No splitting — arbitrary delimiters + // (notably commas inside JSON payloads) used to shatter a single + // logical argument into multiple positional args, which broke any + // skill expecting a JSON-encoded payload. + List argList = (args == null || args.isEmpty()) ? null : args; // RFC-091 settings bridge — pull this skill's stored secrets // (e.g. AIRTABLE_API_KEY) and inject them as env vars for the diff --git a/mateclaw-server/src/main/resources/db/migration/h2/V112__skill_file.sql b/mateclaw-server/src/main/resources/db/migration/h2/V112__skill_file.sql new file mode 100644 index 00000000..bc83eb7f --- /dev/null +++ b/mateclaw-server/src/main/resources/db/migration/h2/V112__skill_file.sql @@ -0,0 +1,24 @@ +-- V112: persist skill bundle files (scripts/ + references/) in the database. +-- +-- Until now scripts/references only lived on the local filesystem of whichever +-- node handled the upload. Multi-instance deployments sharing one MySQL would +-- have the skill row visible everywhere but the script files only on one node, +-- so any other node attempting to run a skill script either failed or ran a +-- stale local copy. Treating the database as the canonical bundle store and +-- the filesystem as a materialized cache resolves that gap and matches the +-- existing pattern for SKILL.md (canonical in mate_skill.skill_content, +-- mirrored to disk by the workspace manager). + +CREATE TABLE IF NOT EXISTS mate_skill_file ( + id BIGINT NOT NULL PRIMARY KEY, + skill_id BIGINT NOT NULL, + file_path VARCHAR(512) NOT NULL, + content CLOB, + content_size INT NOT NULL DEFAULT 0, + sha256 CHAR(64), + create_time DATETIME NOT NULL, + update_time DATETIME NOT NULL +); + +CREATE UNIQUE INDEX IF NOT EXISTS uk_skill_file_path ON mate_skill_file (skill_id, file_path); +CREATE INDEX IF NOT EXISTS idx_skill_file_skill ON mate_skill_file (skill_id); diff --git a/mateclaw-server/src/main/resources/db/migration/mysql/V112__skill_file.sql b/mateclaw-server/src/main/resources/db/migration/mysql/V112__skill_file.sql new file mode 100644 index 00000000..aea34aa7 --- /dev/null +++ b/mateclaw-server/src/main/resources/db/migration/mysql/V112__skill_file.sql @@ -0,0 +1,26 @@ +-- V112: persist skill bundle files (scripts/ + references/) in the database. +-- +-- Until now scripts/references only lived on the local filesystem of whichever +-- node handled the upload. Multi-instance deployments sharing one MySQL would +-- have the skill row visible everywhere but the script files only on one node, +-- so any other node attempting to run a skill script either failed or ran a +-- stale local copy. Treating the database as the canonical bundle store and +-- the filesystem as a materialized cache resolves that gap and matches the +-- existing pattern for SKILL.md (canonical in mate_skill.skill_content, +-- mirrored to disk by the workspace manager). +-- +-- MEDIUMTEXT (16MB) comfortably covers the per-file 1MB cap enforced by +-- ZipSkillFetcher and the 50MB total bundle cap. + +CREATE TABLE IF NOT EXISTS mate_skill_file ( + id BIGINT NOT NULL PRIMARY KEY, + skill_id BIGINT NOT NULL, + file_path VARCHAR(512) NOT NULL, + content MEDIUMTEXT, + content_size INT NOT NULL DEFAULT 0, + sha256 CHAR(64), + create_time DATETIME NOT NULL, + update_time DATETIME NOT NULL, + UNIQUE KEY uk_skill_file_path (skill_id, file_path), + KEY idx_skill_file_skill (skill_id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;