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.
+ *
+ * - Ensure the workspace root exists.
+ * - 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).
+ * - 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.
+ *
+ *
+ * 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:
+ *
+ * - Write every entry from the bundle (overwrites same paths).
+ * - Delete any pre-existing file under references/ or scripts/ that
+ * is NOT in the bundle.
+ *
+ *
+ * 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;